Обратно разпространение
Накратко: Обратното разпространение (backpropagation) смята наклона на грешката за всяко тегло наведнъж с верижното правило, като върви от изхода назад към входа.
Проблем
Девет числа. Кое да мръднеш?
Мрежата от урока за слоевете решаваше правилото за разминаващите се държави, защото теглата бяха избрани на ръка. Истинската мрежа започва с тегла, които не значат нищо, и трябва сама да ги намери.
Ето същата мрежа с начални тегла. Клиент в чужбина плаща с чужда карта от чужд IP, случай (1, 1). Това е наред, етикетът е 0. Мрежата казва 0,4489.
Числата в мрежата са девет: четири тегла и две отмествания в скрития слой, две тегла и отместване на изхода. Кое да мръднеш, накъде и колко? Ако пробваш на сляпо, всеки опит е проход напред. При мрежа с милион тегла (пример за мярка) това са милиони опита за една стъпка.
Идеята
Побутни теглото и гледай грешката
Грешката за едно плащане е (p − y)², квадратът на разликата. Вземи едно тегло, v[0], и го мръдни малко нагоре и малко надолу, с 0,0001. Всеки път смяташ грешката наново.
Мрежата като клас с деветте числа и функциите от предишния урок, плюс грешката:
class Net(double[][] w, double[] b, double[] v, double c){ public double[][] W = w; // скритият слой: тегла и отместване public double[] B = b; public double[] V = v; // изходът: тегла и отместване public double C = c;
public static Net Zeros() => new([[0, 0], [0, 0]], [0, 0], [0, 0], 0);}static double[] Hidden(Net net, double[] x) => [Neuron(x, net.W[0], net.B[0]), Neuron(x, net.W[1], net.B[1])];
static double Forward(Net net, double[] x) => Neuron(Hidden(net, x), net.V, net.C);
static double Loss(Net net, double[] x, double y){ var d = Forward(net, x) - y; return d * d;}Началните тегла и плащането:
// началните тегла: мрежата още не е училаvar net = new Net([[1.5, 1.0], [2.5, 2.0]], [-0.5, -3.0], [1.5, -1.5], -0.3);double[] x = [1, 1]; // чужда карта от чужд IP: клиент в чужбинаdouble y = 0;Console.WriteLine($"p = {Forward(net, x):F4}, грешка = {Loss(net, x, y):F6}");var old = net.V[0];net.V[0] = old + 0.0001;var up = Loss(net, x, y);net.V[0] = old - 0.0001;var down = Loss(net, x, y);net.V[0] = old;Console.WriteLine($"v[0] + 0.0001: грешка = {up:F6}");Console.WriteLine($"v[0] - 0.0001: грешка = {down:F6}");Console.WriteLine($"наклон = {(up - down) / 0.0002:F4}");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].
var h = Hidden(net, x);var p = Neuron(h, net.V, net.C);var a = 2 * (p - y); // как грешката следва pvar s = p * (1 - p); // как p следва сумата си zvar dz = h[0]; // как z следва v[0]Console.WriteLine($"2(p - y) = {a:F4}");Console.WriteLine($"p(1 - p) = {s:F4}");Console.WriteLine($"h[0] = {dz:F4}");Console.WriteLine($"наклон за v[0] = {a * s * dz:F4}");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, и един назад, за всички наклони наведнъж.
static Net Backward(Net net, double[] x, double y){ var h = Hidden(net, x); var output = Neuron(h, net.V, net.C); var g = Net.Zeros(); // делът на изхода var dOut = 2 * (output - y) * output * (1 - output); g.C = dOut; for (var j = 0; j < 2; j++) { g.V[j] = dOut * h[j]; // делът на скрития неврон j var dH = dOut * net.V[j] * h[j] * (1 - h[j]); g.B[j] = dH; for (var i = 0; i < 2; i++) { g.W[j][i] = dH * x[i]; } } return g;}Net.Zeros() е мрежа от нули със същата форма: наклоните имат по един за всяко тегло, затова се пазят в същия клас.Виж го
Побутване срещу backprop, за деветте тегла
За да се обходят деветте числа с един цикъл, get и set ги номерират от 0 до 8. nudge е побутването от по-горе, за тегло номер k:
string[] 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], cstatic double Get(Net n, int k) => k < 4 ? n.W[k / 2][k % 2] : k < 6 ? n.B[k - 4] : k < 8 ? n.V[k - 6] : n.C;
static void Set(Net n, int k, double value){ if (k < 4) n.W[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;}static double Nudge(Net net, int k, double[] x, double y){ var old = Get(net, k); Set(net, k, old + 0.0001); var up = Loss(net, x, y); Set(net, k, old - 0.0001); var down = Loss(net, x, y); Set(net, k, old); return (up - down) / 0.0002;}var g = Backward(net, x, y);Console.WriteLine(" побутване backprop");for (var k = 0; k < 9; k++){ Console.WriteLine($"{names[k],-8}{Nudge(net, k, x, y),10:F4}{Get(g, k),10:F4}");} побутване 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 влизат в изхода в двете посоки.
Виж го
Сигналът напред, грешката назад
static Net Backward(Net net, double[] x, double y){ var h = Hidden(net, x); var output = Neuron(h, net.V, net.C); var g = Net.Zeros(); // делът на изхода var dOut = 2 * (output - y) * output * (1 - output); g.C = dOut; for (var j = 0; j < 2; j++) { g.V[j] = dOut * h[j]; // делът на скрития неврон j var dH = dOut * net.V[j] * h[j] * (1 - h[j]); g.B[j] = dH; for (var 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. Какво ще отпечата: наклонът за това тегло и делът на скрития неврон?
var dOut = 0.4; // делът на изходаvar v = 1.5;var h = 0.5;var dH = dOut * v * h * (1 - h);Console.WriteLine($"{dOut * h:F2} {dH:F2}");Наклонът за теглото е 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 пъти по-големи. Мрежата пак учи, защото посоката е горе-долу вярна, но затова такава грешка живее дълго. Проверката с побутване я хваща веднага.
Виж го
Една стъпка на ученето
Наклоните казват посоката. Стъпката е от градиентното спускане: всяко число минава срещу наклона си, умножен по скоростта на учене. Наклонът е средният за четирите плащания, защото мрежата трябва да е вярна за всички:
static double MeanLoss(Net net, double[][] cases, double[] labels){ var s = 0.0; for (var n = 0; n < cases.Length; n++) { s += Loss(net, cases[n], labels[n]); } return s / cases.Length;}double[][] cases = [[0, 0], [1, 0], [0, 1], [1, 1]];double[] labels = [0, 1, 1, 0];// средният наклон за четирите плащанияvar mean = Net.Zeros();for (var n = 0; n < 4; n++){ var g = Backward(net, cases[n], labels[n]); for (var k = 0; k < 9; k++) { Set(mean, k, Get(mean, k) + Get(g, k) / 4); }}var rate = 2.0; // скоростта на ученеvar before = MeanLoss(net, cases, labels);for (var k = 0; k < 9; k++){ var next = Get(net, k) - rate * Get(mean, k); Console.WriteLine($"{names[k],-8}{Get(net, k),8:F4} → {next:F4}"); Set(net, k, next);}var after = MeanLoss(net, cases, labels);Console.WriteLine($"средна грешка: {before:F4} → {after:F4}");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 (var 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). Кои наклони стават нула и защо?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Градиентно спускане · Класификация: измама или не · Неврон · Слоеве
- Продължава в: Обучението от начало до край · Цифрите от касовата бележка
- Задачи по темата: Невронна мрежа отвътре