Обратно разпространение
Накратко: Обратното разпространение (backpropagation) смята наклона на грешката за всяко тегло наведнъж с верижното правило, като върви от изхода назад към входа.
Проблем
Девет числа. Кое да мръднеш?
Мрежата от урока за слоевете решаваше правилото за разминаващите се държави, защото теглата бяха избрани на ръка. Истинската мрежа започва с тегла, които не значат нищо, и трябва сама да ги намери.
Ето същата мрежа с начални тегла. Клиент в чужбина плаща с чужда карта от чужд IP, случай (1, 1). Това е наред, етикетът е 0. Мрежата казва 0,4489.
Числата в мрежата са девет: четири тегла и две отмествания в скрития слой, две тегла и отместване на изхода. Кое да мръднеш, накъде и колко? Ако пробваш на сляпо, всеки опит е проход напред. При мрежа с милион тегла (пример за мярка) това са милиони опита за една стъпка.
Идеята
Побутни теглото и гледай грешката
Грешката за едно плащане е (p − y)², квадратът на разликата. Вземи едно тегло, v[0], и го мръдни малко нагоре и малко надолу, с 0,0001. Всеки път смяташ грешката наново.
Мрежата като клас с деветте числа и функциите от предишния урок, плюс грешката:
class Net { constructor(w, b, v, c) { this.w = w; // скритият слой: тегла и отместване this.b = b; this.v = v; // изходът: тегла и отместване this.c = c; }
static zeros() { return new Net([[0, 0], [0, 0]], [0, 0], [0, 0], 0); }}function hidden(net, x) { return [neuron(x, net.w[0], net.b[0]), neuron(x, net.w[1], net.b[1])];}
function forward(net, x) { return neuron(hidden(net, x), net.v, net.c);}
function loss(net, x, y) { const d = forward(net, x) - y; return d * d;}Началните тегла и плащането:
// началните тегла: мрежата още не е училаconst net = new Net([[1.5, 1.0], [2.5, 2.0]], [-0.5, -3.0], [1.5, -1.5], -0.3);const x = [1, 1]; // чужда карта от чужд IP: клиент в чужбинаconst y = 0;console.log(`p = ${forward(net, x).toFixed(4)}, грешка = ${loss(net, x, y).toFixed(6)}`);const old = net.v[0];net.v[0] = old + 0.0001;const up = loss(net, x, y);net.v[0] = old - 0.0001;const down = loss(net, x, y);net.v[0] = old;console.log(`v[0] + 0.0001: грешка = ${up.toFixed(6)}`);console.log(`v[0] - 0.0001: грешка = ${down.toFixed(6)}`);console.log(`наклон = ${((up - down) / 0.0002).toFixed(4)}`);p = 0.4489, грешка = 0.201500v[0] + 0.0001: грешка = 0.201520v[0] - 0.0001: грешка = 0.201481наклон = 0.1956Нагоре грешката расте, надолу пада. Разликата, разделена на разстоянието между двете точки (0,0002), е наклонът: 0,1956. Положителен значи „по-голямо v[0] дава по-голяма грешка“. Значи v[0] трябва да намалее. Това е наклонът от градиентното спускане, само че за едно от деветте тегла.
Работи, но иска два прохода напред за всяко тегло. Трябва начин да получиш наклона, без да пипаш теглото.
Идеята
Верижното правило с числа
v[0] не пипа грешката направо. То пипа сумата z на изхода, z пипа p, а p пипа грешката. Верига от три звена. Ако знаеш колко се мести всяко звено, когато мръднеш предишното, наклонът е произведението им:
- Грешката (p − y)² се мести с 2·(p − y), когато мръднеш p.
- p = σ(z) се мести с p·(1 − p), когато мръднеш z. Това е наклонът на сигмоидата.
- z = v[0]·h[0] + v[1]·h[1] + c се мести с h[0], когато мръднеш v[0].
const h = hidden(net, x);const p = neuron(h, net.v, net.c);const a = 2 * (p - y); // как грешката следва pconst s = p * (1 - p); // как p следва сумата си zconst dz = h[0]; // как z следва v[0]console.log(`2(p - y) = ${a.toFixed(4)}`);console.log(`p(1 - p) = ${s.toFixed(4)}`);console.log(`h[0] = ${dz.toFixed(4)}`);console.log(`наклон за v[0] = ${(a * s * dz).toFixed(4)}`);2(p - y) = 0.8978p(1 - p) = 0.2474h[0] = 0.8808наклон за v[0] = 0.19560,8978 × 0,2474 × 0,8808 = 0,1956. Същото число като с побутването, но без нито едно ново изчисление на мрежата. Това е верижното правило: наклонът по веригата е произведението на наклоните на звената.
Идеята
Грешката тръгва назад
Първите две звена са общи за всичко, което е преди изхода. Произведението им е делът на изхода, dOut = 2·(p − y)·p·(1 − p) = 0,2221. Оттам:
- Теглата на изхода: наклонът за v[j] е dOut × h[j].
- Отместването на изхода: наклонът за c е самото dOut, защото z се мести с 1, когато мръднеш c.
- Скритите неврони: h[j] влиза в z с тегло v[j]. Делът му е dH = dOut × v[j] × h[j]·(1 − h[j]): вината на изхода, по силата на реброто, по наклона на неговата сигмоида.
- Теглата на скрития слой: наклонът за w[j][i] е dH × x[i], а за b[j] е dH.
Вината минава назад по същите ребра, по които сигналът мина напред, и всяко тегло получава своя дял. Това е обратното разпространение на грешката (backpropagation, накратко backprop): един проход напред, за да се знаят h и p, и един назад, за всички наклони наведнъж.
function backward(net, x, y) { const h = hidden(net, x); const out = neuron(h, net.v, net.c); const g = Net.zeros(); // делът на изхода const dOut = 2 * (out - y) * out * (1 - out); g.c = dOut; for (let j = 0; j < 2; j++) { g.v[j] = dOut * h[j]; // делът на скрития неврон j const dH = dOut * net.v[j] * h[j] * (1 - h[j]); g.b[j] = dH; for (let i = 0; i < 2; i++) { g.w[j][i] = dH * x[i]; } } return g;}Net.zeros() е мрежа от нули със същата форма: наклоните имат по един за всяко тегло, затова се пазят в същия клас.Виж го
Побутване срещу backprop, за деветте тегла
За да се обходят деветте числа с един цикъл, get и set ги номерират от 0 до 8. nudge е побутването от по-горе, за тегло номер k:
const NAMES = ["w[0][0]", "w[0][1]", "w[1][0]", "w[1][1]", "b[0]", "b[1]", "v[0]", "v[1]", "c"];
// k: w[0][0], w[0][1], w[1][0], w[1][1], b[0], b[1], v[0], v[1], cfunction get(n, k) { if (k < 4) return n.w[Math.floor(k / 2)][k % 2]; if (k < 6) return n.b[k - 4]; if (k < 8) return n.v[k - 6]; return n.c;}
function set(n, k, value) { if (k < 4) n.w[Math.floor(k / 2)][k % 2] = value; else if (k < 6) n.b[k - 4] = value; else if (k < 8) n.v[k - 6] = value; else n.c = value;}function nudge(net, k, x, y) { const old = get(net, k); set(net, k, old + 0.0001); const up = loss(net, x, y); set(net, k, old - 0.0001); const down = loss(net, x, y); set(net, k, old); return (up - down) / 0.0002;}const g = backward(net, x, y);console.log(" побутване backprop");for (let k = 0; k < 9; k++) { const a = nudge(net, k, x, y).toFixed(4).padStart(10); const b = get(g, k).toFixed(4).padStart(10); console.log(`${NAMES[k].padEnd(8)}${a}${b}`);} побутване backpropw[0][0] 0.0350 0.0350w[0][1] 0.0350 0.0350w[1][0] -0.0497 -0.0497w[1][1] -0.0497 -0.0497b[0] 0.0350 0.0350b[1] -0.0497 -0.0497v[0] 0.1956 0.1956v[1] 0.1816 0.1816c 0.2221 0.2221Деветте съвпадат до четвъртия знак. Входовете са (1, 1), затова двете тегла и отместването на един скрит неврон имат еднакъв наклон: всяко от тях мести z на неврона с 1. А двата скрити неврона са с различен знак, защото v[0] = 1,5 и v[1] = −1,5 влизат в изхода в двете посоки.
Виж го
Сигналът напред, грешката назад
function backward(net, x, y) { const h = hidden(net, x); const out = neuron(h, net.v, net.c); const g = Net.zeros(); // делът на изхода const dOut = 2 * (out - y) * out * (1 - out); g.c = dOut; for (let j = 0; j < 2; j++) { g.v[j] = dOut * h[j]; // делът на скрития неврон j const dH = dOut * net.v[j] * h[j] * (1 - h[j]); g.b[j] = dH; for (let i = 0; i < 2; i++) { g.w[j][i] = dH * x[i]; } } return g;}Стъпка по стъпка, с думи
- Началните тегла и случаят x = (1, 1): и двете от чужбина, значи е наред, y = 0.
- Първо напред: h1 = 0,8808, h2 = 0,8176, p = 0,4489.
- Грешката: (0,4489 − 0)² = 0,2015. Мрежата трябва да каже по-малко.
- Назад от изхода: делът е 2(p − y) · p(1 − p) = 0,8978 × 0,2474 = 0,2221, и наклонът за c.
- Наклонът за v[j] е делът по h[j]: 0,2221 × 0,8808 = 0,1956 и 0,2221 × 0,8176 = 0,1816.
- Назад по v и през σ: делът на h1 е 0,2221 × 1,5 × 0,1050 = 0,0350, на h2 е −0,0497. Те са и наклоните за b.
- Наклонът за w[j][i] е делът на h[j] по входа x[i]. И двата входа са 1: 0,0350 и −0,0497.
- Девет наклона от едно минаване назад, същите като с побутването. Тегло с положителен наклон слиза.
Напред минават стойностите, назад минават наклоните. Всеки наклон на ребро е делът на неврона, към който води реброто, по стойността, която е влязла по него.
Пробвай
Предвиди изхода
Делът на изхода е 0,4, теглото към изхода е 1,5, а скритият неврон е дал 0,5. Какво ще отпечата: наклонът за това тегло и делът на скрития неврон?
const dOut = 0.4; // делът на изходаconst v = 1.5;const h = 0.5;const dH = dOut * v * h * (1 - h);console.log(`${(dOut * h).toFixed(2)} ${dH.toFixed(2)}`);Наклонът за теглото е dOut × h = 0,4 × 0,5 = 0,20. Делът на скрития неврон е 0,4 × 1,5 × 0,5 × (1 − 0,5) = 0,15. Без множителя h·(1 − h), наклона на сигмоидата, щеше да излезе 0,30.
Пробвай
Намери грешката
Колега пише backward сам. Мрежата учи, но бавно и странно. Проверката с побутване показва, че наклоните на изхода съвпадат, а на скрития слой са от 7 до 10 пъти по-големи. На кой ред е грешката?
Избери реда с грешката.
Делът на скрития неврон е без наклона на неговата сигмоида, h[j]·(1 − h[j]). Веригата има едно звено по-малко. Тук h·(1 − h) е около 0,1 до 0,15, затова наклоните излизат от 7 до 10 пъти по-големи. Мрежата пак учи, защото посоката е горе-долу вярна, но затова такава грешка живее дълго. Проверката с побутване я хваща веднага.
Виж го
Една стъпка на ученето
Наклоните казват посоката. Стъпката е от градиентното спускане: всяко число минава срещу наклона си, умножен по скоростта на учене. Наклонът е средният за четирите плащания, защото мрежата трябва да е вярна за всички:
function meanLoss(net, cases, labels) { let s = 0; cases.forEach((x, n) => { s += loss(net, x, labels[n]); }); return s / cases.length;}const cases = [[0, 0], [1, 0], [0, 1], [1, 1]];const labels = [0, 1, 1, 0];// средният наклон за четирите плащанияconst mean = Net.zeros();cases.forEach((x, n) => { const g = backward(net, x, labels[n]); for (let k = 0; k < 9; k++) { set(mean, k, get(mean, k) + get(g, k) / 4); }});const rate = 2.0; // скоростта на ученеconst before = meanLoss(net, cases, labels);for (let k = 0; k < 9; k++) { const next = get(net, k) - rate * get(mean, k); console.log(`${NAMES[k].padEnd(8)}${get(net, k).toFixed(4).padStart(8)} → ${next.toFixed(4)}`); set(net, k, next);}const after = meanLoss(net, cases, labels);console.log(`средна грешка: ${before.toFixed(4)} → ${after.toFixed(4)}`);w[0][0] 1.5000 → 1.5147w[0][1] 1.0000 → 1.0210w[1][0] 2.5000 → 2.4863w[1][1] 2.0000 → 1.9926b[0] -0.5000 → -0.4947b[1] -3.0000 → -3.0367v[0] 1.5000 → 1.4987v[1] -1.5000 → -1.5266c -0.3000 → -0.3285средна грешка: 0.2236 → 0.2218Средната грешка пада от 0,2236 на 0,2218. Малко, но в правилната посока. Обучението е същата стъпка повторена хиляди пъти: проход напред, проход назад, стъпка. Целият цикъл и как пада грешката е следващият урок.
Пробвай
Попълни
Каква е стъпката за всяко число на мрежата?
for (let k = 0; k < 9; k++) {
set(net, k, get(net, k) rate * get(g, k));
}Минус: положителен наклон значи, че грешката расте с теглото, затова теглото намалява. С плюс мрежата се катери нагоре и грешката расте с всяка стъпка.
Идеята
Кога backprop боли
- Наклонът изчезва. Наклонът на сигмоидата p·(1 − p) е най-много 0,25. През десет слоя със сигмоида множителите дават най-много 0,25¹⁰, около 0,000001. Първите слоеве почти не учат. Затова скритите слоеве днес са с ReLU: наклонът ѝ е 1 за всяко положително z.
- Наклонът избухва. Ако теглата са големи, произведенията растат през слоевете, а стъпката изхвърля теглата далеч. Помагат по-малка скорост на учене и нормиране.
- Грешка в наклоните не пада, а учи зле. Мрежата пак мърда, само по-бавно или към нещо по-лошо. Затова при собствен backprop първо се сравнява с побутване, както в програмата с деветте тегла.
- Побутването е само проверка. То е точно, но иска два прохода за всяко тегло. За учене е твърде бавно.
- Наклонът казва само „накъде е надолу тук“. Стъпката може да е твърде голяма и да прескочи, а малката е бавна. Това е скоростта на учене от градиентното спускане.
По-точно, за любопитните
Формулата. С L = (p − y)² и p = σ(z):
∂L/∂v₀ = ∂L/∂p · ∂p/∂z · ∂z/∂v₀ = 2(p − y) · p(1 − p) · h₀
∂ се чете „частна производна“: колко се мести лявото, когато мръднеш само дясното, а всичко друго стои. За скрития слой веригата е по-дълга с две звена: ∂z/∂h₀ = v₀ и ∂h₀/∂z₀ = h₀(1 − h₀), после ∂z₀/∂w₀ᵢ = xᵢ.
Защо наклонът на сигмоидата е p(1 − p). Ако σ(z) = 1 / (1 + e^(−z)), производната ѝ е σ(z)·(1 − σ(z)). Удобно е: стойността p вече е сметната в прохода напред, и наклонът идва без нов exp.
Побутването, по-точно. Програмата мръдва теглото и нагоре, и надолу (централна разлика). Грешката на тази оценка е от порядъка на h², при h = 0,0001 около 0,00000001. Ако мръднеш само нагоре, грешката е от порядъка на h и съвпадението е до по-малко знаци.
В истинските библиотеки. Никой не пише backward на ръка за всяка мрежа. PyTorch и подобните пазят всяка операция от прохода напред и после сами минават назад по нея (automatic differentiation): в PyTorch това е loss.backward(). Правилото вътре е същото верижно правило.
Обучението на големи мрежи (с видеокарти и много машини) и пускането им в работа са инфраструктура. Това е в курса ZeroToOps.
Упражнение
Backprop за един неврон
Реши задачата в редактора си.
Плащането от урока за неврона: 230 €, нова сметка, чужда карта, тегла 0,9, 1,4 и 1,8 и отместване −4. Плащането се оказа измама: y = 1. Невронът каза 0,7807. Сметни наклона на грешката (p − y)² за всяко тегло и за отместването с верижното правило: dz = 2·(p − y)·p·(1 − p), за w[i] наклонът е dz × x[i], а за b е dz. Провери теглата с побутване (± 0,0001).
p = 0.7807, грешка = 0.0481w[0]: backprop -0.1727, побутване -0.1727w[1]: backprop -0.0751, побутване -0.0751w[2]: backprop -0.0751, побутване -0.0751b: backprop -0.0751Наклоните са отрицателни: по-големи тегла дават по-голямо p, по-близо до 1, и грешката пада. Теглото на сумата получава най-много вина, защото сумата е най-големият вход (2,3).
Още задачи
- Стъпка. Направи една стъпка със скорост 0,5 и отпечатай новото p. То трябва да е по-близо до 1.
- Друго плащане. Пусни същото за плащането от 40 €, което е наред (y = 0). Кои наклони стават нула и защо?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Градиентно спускане · Класификация: измама или не · Неврон · Слоеве
- Продължава в: Обучението от начало до край · Цифрите от касовата бележка
- Задачи по темата: Невронна мрежа отвътре