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

Ембединги: смисълът като вектори

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

Проблем

Търсене по думи

Ботът на поддръжката има база с готови отговори: по един текст за доставка, връщане и плащане. Клиентът пише въпрос, а ботът трябва да намери текста, който отговаря.

// текст от базата с отговори: тема и текст
record Doc(String topic, String text) {}
// базата с отговори на поддръжката
static final List<Doc> DOCS = List.of(
new Doc("доставка", "доставката отнема до два дни"),
new Doc("връщане", "върнете стоката до 14 дни и ще получите парите"),
new Doc("плащане", "платете с карта или с наложен платеж"));
// въпросите на клиентите
static final String[] QUESTIONS = {
"кога ще дойде пратката ми",
"банката отказа картата ми",
};

Колега търси по общи думи: колкото повече думи от въпроса има в текста, толкова по-добре.

// колко думи от въпроса има и в текста
static int common(String question, String text) {
List<String> ws = List.of(text.split(" "));
int n = 0;
for (String word : question.split(" "))
if (ws.contains(word))
n++;
return n;
}
for (String q : QUESTIONS) {
System.out.println(q);
Doc best = null;
int top = 0;
for (Doc d : DOCS) {
int n = common(q, d.text());
System.out.printf(" %-10s%d%n", d.topic(), n);
if (n > top) {
top = n;
best = d;
}
}
System.out.println(" → " + (best == null ? "няма" : best.topic()));
}
Изход
кога ще дойде пратката ми
доставка 0
връщане 1
плащане 0
→ връщане
банката отказа картата ми
доставка 0
връщане 0
плащане 0
→ няма

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

Трябва сравнение на смисъла, не на буквите.

Идеята

Смисълът като посока

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

Това се записва с числа. Всяка дума става вектор: списък от числа, по едно за всяка ос. Думите с близък смисъл получават близки вектори, тоест стрелки, които сочат почти в една посока. Такъв вектор, който представя смисъла, се казва embedding.

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

Виж го

Думите като вектори

Кратките думи като „ще“, „до“ и „на“ са навсякъде и не казват нищо за темата. Те не са оси и не влизат във векторите:

// кратките думи, които са навсякъде и не казват темата
static final Set<String> STOP = Set.of(
"ви", "е", "до", "с", "на", "от", "и", "да", "се",
"ще", "по", "за", "но", "съм", "много", "или", "над", "преди",
"беше", "в", "след", "не", "при", "ми", "кога");

Пространството: осите и векторът на дума. Векторът на текст е сборът на векторите на думите му.

// думите като вектори: по едно число за всяка ос
class Space {
final String[] text;
final Set<String> stop;
final List<String> vocab;
final List<String> axes;
// осите: най-честите думи без кратките
Space(String[] text, Set<String> stop, int n) {
this.text = text;
this.stop = stop;
Map<String, Integer> freq = new LinkedHashMap<>();
for (String line : text)
for (String w : line.split(" "))
if (!stop.contains(w))
freq.merge(w, 1, Integer::sum);
vocab = new ArrayList<>(freq.keySet());
List<String> all = new ArrayList<>(vocab);
all.sort((a, b) -> freq.get(b) - freq.get(a));
axes = all.subList(0, n);
}
// в колко изречения думата е заедно с думата на всяка ос
int[] of(String word) {
int[] v = new int[axes.size()];
for (String line : text) {
List<String> ws = List.of(line.split(" "));
if (!ws.contains(word))
continue;
for (String w : ws) {
int j = axes.indexOf(w);
if (j >= 0 && !w.equals(word))
v[j]++;
}
}
return v;
}
// векторът на текст: сборът на векторите на думите му
int[] ofText(String s) {
int[] v = new int[axes.size()];
for (String w : s.split(" ")) {
if (stop.contains(w))
continue;
int[] wv = of(w);
for (int j = 0; j < v.length; j++)
v[j] += wv[j];
}
return v;
}
}
Space space = new Space(TEXT, STOP, 8);
System.out.println("оси: " + String.join(" ", space.axes));
for (String word : new String[] {"пратката", "поръчката", "плащането"})
System.out.printf("%-12s%s%n", word, Arrays.toString(space.of(word)));
Изход
оси: куриер стоката доставката поръчката дни пристигне пратката карта
пратката [1, 0, 0, 0, 1, 1, 0, 0]
поръчката [2, 0, 0, 0, 1, 2, 0, 0]
плащането [0, 0, 0, 0, 0, 0, 0, 3]

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

Виж го

Близост: косинус на ъгъла

Колко близки са два вектора, казва ъгълът между тях. Косинусът на ъгъла е 1, когато сочат в една посока, и 0, когато нямат нито една обща ос. Делението на дължините прави така, че значение има само посоката.

// косинусът на ъгъла: 1 е същата посока, 0 нищо общо
static double cosine(int[] a, int[] b) {
double dot = 0, na = 0, nb = 0;
for (int i = 0; i < a.length; i++) {
dot += a[i] * b[i];
na += a[i] * a[i];
nb += b[i] * b[i];
}
if (na == 0 || nb == 0)
return 0;
return dot / (Math.sqrt(na) * Math.sqrt(nb));
}
Space space = new Space(TEXT, STOP, 8);
int[] v = space.of("пратката");
System.out.printf("пратката и поръчката: %.3f%n", cosine(v, space.of("поръчката")));
System.out.printf("пратката и плащането: %.3f%n", cosine(v, space.of("плащането")));
List<String> others = new ArrayList<>(space.vocab);
others.remove("пратката");
others.sort(Comparator.comparingDouble((String o) -> cosine(v, space.of(o))).reversed());
System.out.println("най-близо до пратката:");
for (String o : others.subList(0, 5))
System.out.printf(" %-12s%.3f%n", o, cosine(v, space.of(o)));
Изход
пратката и поръчката: 0.962
пратката и плащането: 0.000
най-близо до пратката:
поръчката 0.962
доставката 0.707
адреса 0.667
три 0.667
стоката 0.667

„пратката“ и „поръчката“ са 0,962: почти една посока. С „плащането“ са 0,000. Най-близките думи до „пратката“ са все за доставка. Има и „три“: в малък текст случайните съседи също дърпат.

Пробвай

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

Какъв е косинусът на [1, 1, 0] и [1, 0, 0]?

System.out.printf("%.2f%n", cosine(new int[] {1, 1, 0}, new int[] {1, 0, 0}));
Отговор

Виж го

Търсене по смисъл

Сега въпросът и всеки текст от базата стават вектори, а отговорът е текстът с най-голям косинус:

Space space = new Space(TEXT, STOP, 8);
for (String q : QUESTIONS) {
int[] v = space.ofText(q);
System.out.println(q);
Doc best = DOCS.get(0);
double top = -1;
for (Doc d : DOCS) {
double c = cosine(v, space.ofText(d.text()));
System.out.printf(" %-10s%.3f%n", d.topic(), c);
if (c > top) {
top = c;
best = d;
}
}
System.out.println(" → " + best.topic() + ": "
+ best.text());
}
Изход
кога ще дойде пратката ми
доставка 0.655
връщане 0.349
плащане 0.000
→ доставка: доставката отнема до два дни
банката отказа картата ми
доставка 0.000
връщане 0.000
плащане 1.000
→ плащане: платете с карта или с наложен платеж

Първият въпрос намира „доставка“ с 0,655, а „връщане“ е 0,349. Вторият намира „плащане“ с 1,000: „банката“ е в едно изречение с „карта“, точно като текста за плащането. Нито един от двата въпроса няма обща дума с отговора си.

Ето първия въпрос като посоки. Векторите са в 8 измерения, а картинката е равна, затова пази само ъгъла на всеки текст до въпроса:

// косинусът на ъгъла: 1 е същата посока, 0 нищо общо
static double cosine(int[] a, int[] b) {
double dot = 0, na = 0, nb = 0;
for (int i = 0; i < a.length; i++) {
dot += a[i] * b[i];
na += a[i] * a[i];
nb += b[i] * b[i];
}
if (na == 0 || nb == 0)
return 0;
return dot / (Math.sqrt(na) * Math.sqrt(nb));
}
Space space = new Space(TEXT, STOP, 8);
for (String q : QUESTIONS) {
int[] v = space.ofText(q);
System.out.println(q);
Doc best = DOCS.get(0);
double top = -1;
for (Doc d : DOCS) {
double c = cosine(v, space.ofText(d.text()));
System.out.printf(" %-10s%.3f%n", d.topic(), c);
if (c > top) {
top = c;
best = d;
}
}
System.out.println(" → " + best.topic() + ": "
+ best.text());
}
Стъпка по стъпка, с думи
  1. Въпросът „кога ще дойде пратката ми“ става вектор: [2, 0, 0, 0, 1, 1, 0, 0]. Картинката пази само ъгъла до него.
  2. „плащане“: косинус 0,000, ъгъл 90°. Нямат нито една обща ос: стрелките са под прав ъгъл.
  3. „връщане“: косинус 0,349, ъгъл 70°. Имат общи оси, но е далече.
  4. „доставка“: косинус 0,655, ъгъл 49°. Най-малкият ъгъл.
  5. Най-близо е „доставка“: „доставката отнема до два дни“. Без нито една обща дума с въпроса.

Пробвай

Попълни

Косинусът дели скаларното произведение на двете дължини. Какво стои на мястото на ___?

return dot / (Math.sqrt(na) * Math.sqrt());

Пробвай

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

Колега смята близостта по-бързо, но получава 11 за първия въпрос и текста за доставка, а близостта трябва да е между 0 и 1. На кой ред е грешката?

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

Идеята

Вниманието като идея

В урока „карта“ има един вектор, където и да е. А в „платете с карта“ и „картата на офисите“ думата значи различни неща: банкова карта и карта на града.

Истинските модели решават това с внимание (attention). Всеки токен гледа другите токени в текста и смесва в своя вектор малко от техните, повече от тези, които са важни за него. „карта“ до „платете“ взема от „платете“, а до „офисите“ от „офисите“, и двата вектора стават различни. Това се повтаря през много слоеве, и така векторът на всеки токен носи смисъла му в точно това изречение. Векторът на целия текст се смята от тях.

Тук вниманието е само идея: как точно се смята, не е част от курса.

Идеята

Кога боли

  • Малкият модел знае думи, не смисъл. Клиент иска да върне стоката, а търсенето го праща на доставката:

    Space space = new Space(TEXT, STOP, 8);
    for (String q : new String[] {"искам да върна стоката"}) {
    int[] v = space.ofText(q);
    System.out.println(q);
    Doc best = DOCS.get(0);
    double top = -1;
    for (Doc d : DOCS) {
    double c = cosine(v, space.ofText(d.text()));
    System.out.printf(" %-10s%.3f%n", d.topic(), c);
    if (c > top) {
    top = c;
    best = d;
    }
    }
    System.out.println(" → " + best.topic() + ": "
    + best.text());
    }
    Изход
    искам да върна стоката
    доставка 0.842
    връщане 0.635
    плащане 0.000
    → доставка: доставката отнема до два дни

    „върна“ я няма в текста: там са „върнете“, „върнати“, „връщат“ и „връщането“, и за модела това са различни думи, както при речника от урока за токените. Остава „стоката“, а нейните съседи в текста са „доставката“, „дни“ и „пристигне“. Освен това думата не брои собствената си ос, а точно „стоката“ е най-силната ос на текста за връщане. Векторът на въпроса излиза [0, 0, 1, 0, 1, 1, 0, 0] и сочи към доставката (0,842), а не към връщането (0,635). На английски точно този въпрос намира връщането, но „i want my money back“ отива на плащането: „money“ и „back“ са в едно изречение с „card“. Пропуските на двата езика са различни, защото текстовете са различни.

    Истинският модел за вектори е учен върху огромно количество текст. Той знае, че „върна“, „върнете“ и „връщане“ са едно и също, и вижда целия въпрос, не само отделните думи. Пак може да сбърка, но много по-рядко. Колко често намира правилния текст, се мери: в модула за търсене по смисъл това е recall@k, делът на въпросите, чийто правилен текст е сред първите k намерени.

  • Близко не значи същото. Думите с обратен смисъл често стоят в едни и същи изречения:

    Space space = new Space(TEXT, STOP, 8);
    for (String[] p : new String[][] {{"безплатна", "струва"}, {"бързо", "закъсня"}})
    System.out.printf("%s и %s: %.3f%n", p[0], p[1], cosine(space.of(p[0]), space.of(p[1])));
    System.out.println("цвят: " + Arrays.toString(space.of("цвят")));
    Изход
    безплатна и струва: 0.894
    бързо и закъсня: 0.577
    цвят: [0, 0, 0, 0, 0, 0, 0, 0]

    „безплатна“ и „струва“ са 0,894, защото и двете са до „доставката“. Истинските модели се справят по-добре, но „може“ и „не може“ пак излизат близо.

  • Непозната дума е нула. „цвят“ го няма в текста и векторът му е само нули. Въпрос само с непознати думи е 0,000 до всичко, но търсенето пак връща някой текст. Затова трябва праг: виж упражнението.

  • Векторите идват от текста. Малък текст дава случайни съседи („три“ до „пратката“). Различни модели дават различни вектори, които не се сравняват помежду си: базата и въпросът трябва да са с един и същ модел.

  • Много текстове, много вектори. За хиляди документи векторите се пазят в база, която търси най-близките бързо: векторните бази. Търсенето по смисъл върху документите на магазина е цял модул по-нататък в курса. Как се пуска модел за вектори на сървър и как се поддържа индексът е работа за курса ZeroToOps.

По-точно, за любопитните

Истинските embeddings. Моделите за вектори приемат текст и връщат стотици или хиляди числа. Викат се през API като другите модели или се пускат локално, най-често от Python. Векторите обикновено са с дължина 1, и тогава косинусът е просто скаларното произведение.

Нулите на осите. В урока думата не брои собствената си ос. Затова думата на ос, която се среща само с думи извън осите, е само нули: „карта“ е ос, но векторът ѝ е [0, 0, 0, 0, 0, 0, 0, 0], и самата дума „карта“ не помага на търсенето. Текстът за плащане се намира заради „наложен“ и „платеж“, които са в едно изречение с „карта“. Ако думата брои и своята ос, тя става най-голямото число във вектора си и дърпа всичко към себе си: тогава „пратката“ и „поръчката“ падат от 0,962 на 0,197. И двата избора са компромис на малкия модел; истинските модели нямат оси, които са думи.

Защо косинус, а не разстояние. Разстоянието между върховете зависи и от дължината, а дължината тук е колко думи има текстът. Косинусът гледа само посоката. При вектори с дължина 1 двете дават една и съща подредба.

Най-близките k. Търсенето в урока е k-NN от урока за най-близките съседи с k = 1: само векторите и мярката за близост са други.

Упражнение

Не знам е отговор

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

Ботът не бива да отговаря наслуки. Напиши функция, която връща най-близкия текст само ако косинусът е поне 0,5, а иначе „не знам, пращам на колега“. Пробвай я с двата въпроса от урока и с „искам друг цвят“.

Изход
кога ще дойде пратката ми → доставката отнема до два дни
банката отказа картата ми → платете с карта или с наложен платеж
искам друг цвят → не знам, пращам на колега

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

Още задачи

  • Повече оси. Направи пространството с 16 оси. Как се сменят близостите от урока?
  • Първите две. Отпечатай двата най-близки текста за всеки въпрос и колко далече е вторият от първия.

Тест

Провери се

1. Какво е embedding?
2. Два вектора сочат в една посока, но единият е три пъти по-дълъг. Какъв е косинусът им?
3. Защо търсенето по смисъл намира „доставката отнема до два дни“ за „кога ще дойде пратката ми“?
4. „безплатна“ и „струва“ имат близост 0,894. Какво казва това?

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