Към съдържанието
wannacode

Предсказване на следващия токен

Накратко: Езиковият модел пише текст, като смята вероятностите на следващия токен и избира един; температурата и top-k решават колко случаен е изборът.

Проблем

Едно начало, три отговора

Ботът на поддръжката трябва да дописва отговори. Колега пуска един и същ начален текст, „поръчката“, три пъти, с различно семе на генератора. Ето трите отговора:

var table = countNext(TEXT);
for (int seed : new int[] {2026, 34567, 12345})
System.out.println("семе " + seed + ": " + generate(table, "поръчката", 1, 100, new Rng(seed)));
Изход
семе 2026: поръчката ще пристигне до три дни
семе 34567: поръчката пристигна в офиса на куриер
семе 12345: поръчката ще ви се връщат по картата ви утре

Първият звучи разумно. Вторият е почти изречение. Третият е безсмислица, макар че всяка две съседни думи са от текста на магазина. И трите са от една и съща програма, а програмата не търси отговор никъде: тя пише дума по дума.

Откъде знае коя дума да сложи? И защо всеки път е различно?

Идеята

Вероятността на следващата дума

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

Текстът се пише с цикъл:

  1. Моделът дава вероятностите на следващия токен.
  2. Един токен се избира според тях.
  3. Добавя се към текста и всичко се повтаря, докато дойде краят или лимитът.

Изборът е отделна стъпка, а не част от модела. Затова един и същ модел може да дава различни отговори.

Моделът в урока е най-малкият възможен: таблица колко пъти коя дума е идвала точно след коя. Гледа само последната дума и брои цели думи, а не токени. Истинските модели са невронни мрежи, които гледат целия текст, но дават същото: вероятностите на следващия токен.

Виж го

Таблицата: коя дума след коя

Текстът на магазина е същият като в урока за токените. За всяко изречение броим всяка двойка съседни думи. Точката „.“ отбелязва края на изречението, за да знае моделът кога да спре.

// една възможна следваща дума и вероятността ѝ
record Choice(String word, double p) {}
// колко пъти коя дума идва точно след коя; „.“ е краят на изречението
static Map<String, Map<String, Integer>> countNext(String[] text) {
Map<String, Map<String, Integer>> table = new LinkedHashMap<>();
for (String line : text) {
String[] w = (line + " .").split(" ");
for (int i = 0; i + 1 < w.length; i++)
table.computeIfAbsent(w[i], key -> new LinkedHashMap<>())
.merge(w[i + 1], 1, Integer::sum);
}
return table;
}

Вероятността на следващата дума е броят ѝ, делен на сбора. Засега гледай само t = 1 и „всички думи“: тогава теглото е самият брой. Температурата t и k идват по-долу.

// k-те най-чести думи, тегло брой^(1/t) / сбора
static List<Choice> nextWords(Map<String, Integer> counts,
double t, int k) {
List<String> words = new ArrayList<>(counts.keySet());
words.sort((a, b) -> counts.get(b) - counts.get(a));
List<String> top =
words.subList(0, Math.min(k, words.size()));
double total = 0;
for (String w : top)
total += Math.pow(counts.get(w), 1 / t);
List<Choice> choices = new ArrayList<>();
for (String w : top) {
double weight = Math.pow(counts.get(w), 1 / t);
choices.add(new Choice(w, weight / total));
}
return choices;
}
var table = countNext(TEXT);
Map<String, Integer> counts = table.get("ще");
System.out.println("след \"ще\":");
for (Choice c : nextWords(counts, 1, 100))
System.out.printf("%-11s%2d %5.1f%%%n", c.word(), counts.get(c.word()), c.p() * 100);
Изход
след "ще":
пристигне 4 40.0%
бъде 2 20.0%
дойде 1 10.0%
ви 1 10.0%
бъдат 1 10.0%
получите 1 10.0%

След „ще“ в текста идват шест различни думи, общо 10 пъти. „пристигне“ е 4 от тях: 40,0%. Ето това е езиковият модел: за „ще“ той казва „пристигне“ с 40,0%, „бъде“ с 20,0% и всяка от другите с 10,0%.

Виж го

Изборът: число от 0 до 1

Как се избира дума с вероятност 40,0%? Генераторът със семе дава число r от 0 до 1. Лентите на думите застават една до друга: „пристигне“ от 0 до 0,4, „бъде“ от 0,4 до 0,6 и така нататък. Думата, в чиято лента пада r, е избраната.

class Rng {
private long state;
Rng(long seed) {
state = seed;
}
// следващото число между 0 и 1; едно семе дава едни и същи числа
double next() {
state = state * 48271 % 2147483647;
return (double) state / 2147483647;
}
}
// число от 0 до 1 пада в една от лентите
static String pick(List<Choice> choices, Rng rng) {
double r = rng.next();
double sum = 0;
for (Choice c : choices) {
sum += c.p();
if (r < sum)
return c.word();
}
return choices.get(choices.size() - 1).word();
}

Генерирането повтаря това дума по дума от началото, докато дойде „.“ или 12 думи:

// продължи текста дума по дума до „.“ или до 12 думи
static String generate(Map<String, Map<String, Integer>> table,
String start, double t, int k, Rng rng) {
List<String> text = new ArrayList<>(List.of(start.split(" ")));
String word = text.get(text.size() - 1);
while (text.size() < 12) {
word = pick(nextWords(table.get(word), t, k), rng);
if (word.equals("."))
break;
text.add(word);
}
return String.join(" ", text);
}

Ако винаги вземаш най-вероятната дума (k = 1), генераторът няма какво да избира и отговорът е един и същ при всяко семе:

var table = countNext(TEXT);
for (int seed : new int[] {2026, 34567, 12345})
System.out.println("семе " + seed + ": " + generate(table, "поръчката", 1, 1, new Rng(seed)));
Изход
семе 2026: поръчката ще пристигне до два дни
семе 34567: поръчката ще пристигне до два дни
семе 12345: поръчката ще пристигне до два дни

Със случаен избор отговорът зависи от семето. Едно и също семе обаче дава едни и същи числа, значи и един и същ отговор:

var table = countNext(TEXT);
for (int seed : new int[] {2026, 2026, 34567})
System.out.println("семе " + seed + ": " + generate(table, "поръчката", 1, 100, new Rng(seed)));
Изход
семе 2026: поръчката ще пристигне до три дни
семе 2026: поръчката ще пристигне до три дни
семе 34567: поръчката пристигна в офиса на куриер

Затова в началото трите отговора бяха различни: всяко семе дава други числа, а оттам други думи.

Пробвай

Предвиди изхода

След „доставката“ в текста има пет продължения, едното е краят „.“. Какво печата програмата с k = 1?

var table = countNext(TEXT);
for (Choice c : nextWords(table.get("доставката"), 1, 1))
System.out.printf("%s %.1f%%%n", c.word(), c.p() * 100);
Отговор

Идеята

Температура и top-k

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

  • температура t: теглото е брой^(1/t). При t под 1 честите думи стават още по-вероятни и отговорът е по-предвидим. При t над 1 вероятностите се изравняват и редките думи излизат по-често.
  • top-k: остават само k-те най-чести думи, а другите не могат да се изберат. Сборът се смята само по тях, затова пак е 100%.
var table = countNext(TEXT);
Map<String, Integer> counts = table.get("ще");
double[] temps = {0.5, 1.0, 2.0};
List<List<Choice>> rows = new ArrayList<>();
StringBuilder head = new StringBuilder(" ".repeat(11));
for (double t : temps) {
rows.add(nextWords(counts, t, 100));
head.append(String.format("%7s", "t=" + (t == (int) t ? String.valueOf((int) t) : String.valueOf(t))));
}
System.out.println(head);
for (int i = 0; i < counts.size(); i++) {
StringBuilder line = new StringBuilder(String.format("%-11s", rows.get(0).get(i).word()));
for (List<Choice> row : rows)
line.append(String.format("%6.1f%%", row.get(i).p() * 100));
System.out.println(line);
}
Изход
t=0.5 t=1 t=2
пристигне 66.7% 40.0% 27.0%
бъде 16.7% 20.0% 19.1%
дойде 4.2% 10.0% 13.5%
ви 4.2% 10.0% 13.5%
бъдат 4.2% 10.0% 13.5%
получите 4.2% 10.0% 13.5%

При t = 0,5 „пристигне“ е 66,7%, а при t = 2 само 27,0%. Думите, видени веднъж, са 4,2%, 10,0% и 13,5%: колкото по-висока е температурата, толкова по-често моделът ги избира.

Ето лентите след „ще“ при трите температури, после с k = 3 и изборът с едно число от генератора:

// k-те най-чести думи, тегло брой^(1/t) / сбора
static List<Choice> nextWords(Map<String, Integer> counts,
double t, int k) {
List<String> words = new ArrayList<>(counts.keySet());
words.sort((a, b) -> counts.get(b) - counts.get(a));
List<String> top =
words.subList(0, Math.min(k, words.size()));
double total = 0;
for (String w : top)
total += Math.pow(counts.get(w), 1 / t);
List<Choice> choices = new ArrayList<>();
for (String w : top) {
double weight = Math.pow(counts.get(w), 1 / t);
choices.add(new Choice(w, weight / total));
}
return choices;
}
// число от 0 до 1 пада в една от лентите
static String pick(List<Choice> choices, Rng rng) {
double r = rng.next();
double sum = 0;
for (Choice c : choices) {
sum += c.p();
if (r < sum)
return c.word();
}
return choices.get(choices.size() - 1).word();
}
Стъпка по стъпка, с думи
  1. След „ще“ в текста идват 6 думи. Лентата е броят, делен на всички 10: „пристигне“ е 4 от 10, 40,0%.
  2. t = 0,5: теглото е брой². Честата дума става още по-вероятна: „пристигне“ 66,7%.
  3. t = 2: теглото е √брой. Лентите се изравняват: „пристигне“ 27,0%, „получите“ 13,5%.
  4. t = 1, k = 3: остават само трите най-чести и сборът им пак е 100%: 57,1%, 28,6%, 14,3%.
  5. Трите ленти застават една до друга от 0 до 1. Генераторът със семе 31337 дава 0,7044.
  6. 0,7044 пада в лентата на „бъде“ (от 57,1% до 85,7%): следващата дума е „бъде“, не най-честата.
var table = countNext(TEXT);
List<Choice> choices = nextWords(table.get("ще"), 1, 3);
System.out.println("k=3:");
for (Choice c : choices)
System.out.printf("%-11s%5.1f%%%n", c.word(), c.p() * 100);
Rng rng = new Rng(31337);
Rng copy = new Rng(31337);
System.out.printf("число от генератора: %.4f%n", copy.next());
System.out.println("избрана: " + pick(choices, rng));
Изход
k=3:
пристигне 57.1%
бъде 28.6%
дойде 14.3%
число от генератора: 0.7044
избрана: бъде

С k = 3 остават „пристигне“, „бъде“ и „дойде“, а вероятностите им са 4, 2 и 1 от 7. Генераторът със семе 31337 дава 0,7044, а то пада в лентата на „бъде“, от 57,1% до 85,7%. Изборът е по вероятностите, но не е винаги най-вероятната дума.

Пробвай

Попълни

Кое число стои на мястото на ___ в теглото на думата?

for (String w : top)
    total += Math.pow(counts.get(w), 1 / );

Пробвай

Намери грешката

Колега иска top-k с трите най-вероятни думи, но моделът му пише само редки думи. На кой ред е грешката?

Избери реда с грешката.

Идеята

Кога боли

  • Вероятното не е вярно. Моделът пише „до три дни“ толкова лесно, колкото „до два дни“: и двете са в текста. Той не знае кога пристига точно тази поръчка. Гладкият текст звучи уверено и когато е грешен. За това е урокът за границите на модела.
  • Всяка двойка е вярна, изречението не е. „поръчката ще ви се връщат по картата ви утре“ е от съседни думи, които съществуват. Нашият модел гледа само една дума назад. Истинските гледат целия текст и пишат много по-добре, но пак могат да слепят нещо гладко и безсмислено.
  • Висока температура, повече грешки. Редките продължения излизат по-често. За отговори на клиенти, където трябва точност, температурата е ниска. За идеи и варианти може да е по-висока.
  • Едно семе не е обещание. В урока едно семе дава един отговор. При истински модел през API една и съща заявка понякога дава различен отговор и при едни и същи настройки, затова не разчитай на точно същия текст, а проверявай отговора.
По-точно, за любопитните

Softmax и температурата. Истинският модел е невронна мрежа: за всеки токен от речника тя дава число (logit), а softmax ги прави вероятности. Температурата дели числата на t преди softmax. Теглото брой^(1/t) от урока е точно това: softmax от log(брой) / t.

top-p. Освен top-k има и top-p: остават най-честите токени, докато сборът им стигне p (например 0,9). Така при уверен модел остават малко токени, а при неуверен повече.

Токени, не думи. Истинският модел избира токени от речника си, а думата се получава от няколко избора поред. Температурата, top-k и семето се задават в заявката към модела (урокът за извикването).

Колко назад гледа. Нашият модел помни една дума. Истинският вижда целия разговор до лимита си, и това е прозорецът на контекста.

Упражнение

Колко вероятно е изречението

Реши в редактора си.

Вероятността на цяло изречение е произведението на вероятностите на всяка следваща дума, включително „.“ накрая. Напиши функция, която я смята с таблицата от урока, и я отпечатай в проценти за три изречения. Ако двойка думи никога не е виждана, вероятността ѝ е 0.

Изход
пратката ще пристигне до два дни: 2.86%
пратката ще пристигне до 14 дни: 1.43%
пратката пристигна утре: 0.00%

Първото изречение е 2,86%, второто 1,43%, макар че и двете не са в текста буква по буква: всички двойки в тях са виждани. Третото е 0,00%: „пратката“ и „пристигна“ са в текста, но никога една след друга. Така моделът казва колко „естествен“ му изглежда текстът.

Още задачи

  • Най-вероятното изречение. Започни от „пратката“ и всеки път вземи най-вероятната дума. Каква е вероятността на изречението, което се получава?
  • Две думи назад. Брой коя дума идва след двойка думи, не след една. Как се променят отговорите от началото на урока?

Тест

Провери се

1. Какво дава езиковият модел, когато му подадеш текст?
2. Пускаш генерирането два пъти със семе 2026. Какво ще видиш?
3. Как температурата t = 0,5 променя вероятностите след „ще“?
4. Ботът пише „поръчката ще пристигне до три дни“, а реално доставката е до два дни. Защо?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·