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

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

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

Проблем

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

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

// текст от базата с отговори: тема и текст
record Doc(string Topic, string Text);
// базата с отговори на поддръжката
Doc[] docs =
[
new("доставка", "доставката отнема до два дни"),
new("връщане", "върнете стоката до 14 дни и ще получите парите"),
new("плащане", "платете с карта или с наложен платеж"),
];
// въпросите на клиентите
string[] questions =
[
"кога ще дойде пратката ми",
"банката отказа картата ми",
];

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

// колко думи от въпроса има и в текста
static int Common(string question, string text)
{
var ws = text.Split(' ');
return question.Split(' ').Count(word => ws.Contains(word));
}
foreach (var q in questions)
{
Console.WriteLine(q);
Doc? best = null;
int top = 0;
foreach (var d in docs)
{
int n = Common(q, d.Text);
Console.WriteLine($" {d.Topic,-10}{n}");
if (n > top)
{
top = n;
best = d;
}
}
Console.WriteLine(" → " + (best?.Topic ?? "няма"));
}
Изход
кога ще дойде пратката ми
доставка 0
връщане 1
плащане 0
→ връщане
банката отказа картата ми
доставка 0
връщане 0
плащане 0
→ няма

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

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

Идеята

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

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

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

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

Виж го

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

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

// кратките думи, които са навсякъде и не казват темата
HashSet<string> stop =
[
"ви", "е", "до", "с", "на", "от", "и", "да", "се",
"ще", "по", "за", "но", "съм", "много", "или", "над", "преди",
"беше", "в", "след", "не", "при", "ми", "кога",
];

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

// думите като вектори: по едно число за всяка ос
class Space
{
readonly string[] text;
readonly HashSet<string> stop;
public readonly List<string> Vocab;
public readonly List<string> Axes;
// осите: най-честите думи без кратките
public Space(string[] text, HashSet<string> stop, int n)
{
this.text = text;
this.stop = stop;
var freq = new OrderedDictionary<string, int>();
foreach (var line in text)
foreach (var w in line.Split(' '))
if (!stop.Contains(w))
freq[w] = freq.GetValueOrDefault(w) + 1;
Vocab = freq.Keys.ToList();
Axes = Vocab.OrderByDescending(w => freq[w]).Take(n).ToList();
}
// в колко изречения думата е заедно с думата на всяка ос
public int[] Of(string word)
{
var v = new int[Axes.Count];
foreach (var line in text)
{
var ws = line.Split(' ');
if (!ws.Contains(word))
continue;
foreach (var w in ws)
{
int j = Axes.IndexOf(w);
if (j >= 0 && w != word)
v[j]++;
}
}
return v;
}
// векторът на текст: сборът на векторите на думите му
public int[] OfText(string s)
{
var v = new int[Axes.Count];
foreach (var w in s.Split(' '))
{
if (stop.Contains(w))
continue;
var wv = Of(w);
for (int j = 0; j < v.Length; j++)
v[j] += wv[j];
}
return v;
}
}
var space = new Space(text, stop, 8);
Console.WriteLine("оси: " + string.Join(" ", space.Axes));
foreach (var word in new[] { "пратката", "поръчката", "плащането" })
Console.WriteLine($"{word,-12}[{string.Join(", ", 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));
}
var space = new Space(text, stop, 8);
var v = space.Of("пратката");
Console.WriteLine($"пратката и поръчката: {Cosine(v, space.Of("поръчката")):F3}");
Console.WriteLine($"пратката и плащането: {Cosine(v, space.Of("плащането")):F3}");
var others = space.Vocab.Where(o => o != "пратката")
.OrderByDescending(o => Cosine(v, space.Of(o))).Take(5);
Console.WriteLine("най-близо до пратката:");
foreach (var o in others)
Console.WriteLine($" {o,-12}{Cosine(v, space.Of(o)):F3}");
Изход
пратката и поръчката: 0.962
пратката и плащането: 0.000
най-близо до пратката:
поръчката 0.962
доставката 0.707
адреса 0.667
три 0.667
стоката 0.667

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

Пробвай

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

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

Console.WriteLine($"{Cosine([1, 1, 0], [1, 0, 0]):F2}");
Отговор

Виж го

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

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

var space = new Space(text, stop, 8);
foreach (var q in questions)
{
var v = space.OfText(q);
Console.WriteLine(q);
var best = docs[0];
double top = -1;
foreach (var d in docs)
{
double c = Cosine(v, space.OfText(d.Text));
Console.WriteLine($" {d.Topic,-10}{c:F3}");
if (c > top)
{
top = c;
best = d;
}
}
Console.WriteLine($" → {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));
}
var space = new Space(text, stop, 8);
foreach (var q in questions)
{
var v = space.OfText(q);
Console.WriteLine(q);
var best = docs[0];
double top = -1;
foreach (var d in docs)
{
double c = Cosine(v, space.OfText(d.Text));
Console.WriteLine($" {d.Topic,-10}{c:F3}");
if (c > top)
{
top = c;
best = d;
}
}
Console.WriteLine($" → {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). Всеки токен гледа другите токени в текста и смесва в своя вектор малко от техните, повече от тези, които са важни за него. „карта“ до „платете“ взема от „платете“, а до „офисите“ от „офисите“, и двата вектора стават различни. Това се повтаря през много слоеве, и така векторът на всеки токен носи смисъла му в точно това изречение. Векторът на целия текст се смята от тях.

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

Идеята

Кога боли

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

    var space = new Space(text, stop, 8);
    foreach (var q in new[] { "искам да върна стоката" })
    {
    var v = space.OfText(q);
    Console.WriteLine(q);
    var best = docs[0];
    double top = -1;
    foreach (var d in docs)
    {
    double c = Cosine(v, space.OfText(d.Text));
    Console.WriteLine($" {d.Topic,-10}{c:F3}");
    if (c > top)
    {
    top = c;
    best = d;
    }
    }
    Console.WriteLine($" → {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 намерени.

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

    var space = new Space(text, stop, 8);
    foreach (var (x, y) in new[] { ("безплатна", "струва"), ("бързо", "закъсня") })
    Console.WriteLine($"{x} и {y}: {Cosine(space.Of(x), space.Of(y)):F3}");
    Console.WriteLine($"цвят: [{string.Join(", ", 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 за достъпно обучение по програмиране ·