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

Обратно разпространение

Накратко: Обратното разпространение (backpropagation) смята наклона на грешката за всяко тегло наведнъж с верижното правило, като върви от изхода назад към входа.

Проблем

Девет числа. Кое да мръднеш?

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

Ето същата мрежа с начални тегла. Клиент в чужбина плаща с чужда карта от чужд IP, случай (1, 1). Това е наред, етикетът е 0. Мрежата казва 0,4489.

Числата в мрежата са девет: четири тегла и две отмествания в скрития слой, две тегла и отместване на изхода. Кое да мръднеш, накъде и колко? Ако пробваш на сляпо, всеки опит е проход напред. При мрежа с милион тегла (пример за мярка) това са милиони опита за една стъпка.

Идеята

Побутни теглото и гледай грешката

Грешката за едно плащане е (p − y)², квадратът на разликата. Вземи едно тегло, v[0], и го мръдни малко нагоре и малко надолу, с 0,0001. Всеки път смяташ грешката наново.

Мрежата като клас с деветте числа и функциите от предишния урок, плюс грешката:

class Net {
double[][] w; // скритият слой: тегла и отместване
double[] b;
double[] v; // изходът: тегла и отместване
double c;
Net(double[][] w, double[] b, double[] v, double c) {
this.w = w;
this.b = b;
this.v = v;
this.c = c;
}
static Net zeros() {
return new Net(new double[2][2], new double[2], new double[2], 0);
}
}
static double[] hidden(Net net, double[] x) {
return new double[] {neuron(x, net.w[0], net.b[0]), neuron(x, net.w[1], net.b[1])};
}
static double forward(Net net, double[] x) {
return neuron(hidden(net, x), net.v, net.c);
}
static double loss(Net net, double[] x, double y) {
double d = forward(net, x) - y;
return d * d;
}

Началните тегла и плащането:

// началните тегла: мрежата още не е учила
Net net = new Net(new double[][] {{1.5, 1.0}, {2.5, 2.0}},
new double[] {-0.5, -3.0}, new double[] {1.5, -1.5}, -0.3);
double[] x = {1, 1}; // чужда карта от чужд IP: клиент в чужбина
double y = 0;
System.out.printf("p = %.4f, грешка = %.6f%n", forward(net, x), loss(net, x, y));
double old = net.v[0];
net.v[0] = old + 0.0001;
double up = loss(net, x, y);
net.v[0] = old - 0.0001;
double down = loss(net, x, y);
net.v[0] = old;
System.out.printf("v[0] + 0.0001: грешка = %.6f%n", up);
System.out.printf("v[0] - 0.0001: грешка = %.6f%n", down);
System.out.printf("наклон = %.4f%n", (up - down) / 0.0002);
Изход
p = 0.4489, грешка = 0.201500
v[0] + 0.0001: грешка = 0.201520
v[0] - 0.0001: грешка = 0.201481
наклон = 0.1956

Нагоре грешката расте, надолу пада. Разликата, разделена на разстоянието между двете точки (0,0002), е наклонът: 0,1956. Положителен значи „по-голямо v[0] дава по-голяма грешка“. Значи v[0] трябва да намалее. Това е наклонът от градиентното спускане, само че за едно от деветте тегла.

Работи, но иска два прохода напред за всяко тегло. Трябва начин да получиш наклона, без да пипаш теглото.

Идеята

Верижното правило с числа

v[0] не пипа грешката направо. То пипа сумата z на изхода, z пипа p, а p пипа грешката. Верига от три звена. Ако знаеш колко се мести всяко звено, когато мръднеш предишното, наклонът е произведението им:

  1. Грешката (p − y)² се мести с 2·(p − y), когато мръднеш p.
  2. p = σ(z) се мести с p·(1 − p), когато мръднеш z. Това е наклонът на сигмоидата.
  3. z = v[0]·h[0] + v[1]·h[1] + c се мести с h[0], когато мръднеш v[0].
double[] h = hidden(net, x);
double p = neuron(h, net.v, net.c);
double a = 2 * (p - y); // как грешката следва p
double s = p * (1 - p); // как p следва сумата си z
double dz = h[0]; // как z следва v[0]
System.out.printf("2(p - y) = %.4f%n", a);
System.out.printf("p(1 - p) = %.4f%n", s);
System.out.printf("h[0] = %.4f%n", dz);
System.out.printf("наклон за v[0] = %.4f%n", a * s * dz);
Изход
2(p - y) = 0.8978
p(1 - p) = 0.2474
h[0] = 0.8808
наклон за v[0] = 0.1956

0,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) {
double[] h = hidden(net, x);
double out = neuron(h, net.v, net.c);
Net g = Net.zeros();
// делът на изхода
double dOut = 2 * (out - y) * out * (1 - out);
g.c = dOut;
for (int j = 0; j < 2; j++) {
g.v[j] = dOut * h[j];
// делът на скрития неврон j
double dH = dOut * net.v[j] * h[j] * (1 - h[j]);
g.b[j] = dH;
for (int i = 0; i < 2; i++) {
g.w[j][i] = dH * x[i];
}
}
return g;
}
Net.zeros() е мрежа от нули със същата форма: наклоните имат по един за всяко тегло, затова се пазят в същия клас.

Виж го

Побутване срещу backprop, за деветте тегла

За да се обходят деветте числа с един цикъл, get и set ги номерират от 0 до 8. nudge е побутването от по-горе, за тегло номер k:

static final 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], c
static double get(Net n, int k) {
if (k < 4) return n.w[k / 2][k % 2];
if (k < 6) return n.b[k - 4];
if (k < 8) return n.v[k - 6];
return 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) {
double old = get(net, k);
set(net, k, old + 0.0001);
double up = loss(net, x, y);
set(net, k, old - 0.0001);
double down = loss(net, x, y);
set(net, k, old);
return (up - down) / 0.0002;
}
Net g = backward(net, x, y);
System.out.println(" побутване backprop");
for (int k = 0; k < 9; k++) {
System.out.printf("%-8s%10.4f%10.4f%n", NAMES[k], nudge(net, k, x, y), get(g, k));
}
Изход
побутване backprop
w[0][0] 0.0350 0.0350
w[0][1] 0.0350 0.0350
w[1][0] -0.0497 -0.0497
w[1][1] -0.0497 -0.0497
b[0] 0.0350 0.0350
b[1] -0.0497 -0.0497
v[0] 0.1956 0.1956
v[1] 0.1816 0.1816
c 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) {
double[] h = hidden(net, x);
double out = neuron(h, net.v, net.c);
Net g = Net.zeros();
// делът на изхода
double dOut = 2 * (out - y) * out * (1 - out);
g.c = dOut;
for (int j = 0; j < 2; j++) {
g.v[j] = dOut * h[j];
// делът на скрития неврон j
double dH = dOut * net.v[j] * h[j] * (1 - h[j]);
g.b[j] = dH;
for (int i = 0; i < 2; i++) {
g.w[j][i] = dH * x[i];
}
}
return g;
}
Стъпка по стъпка, с думи
  1. Началните тегла и случаят x = (1, 1): и двете от чужбина, значи е наред, y = 0.
  2. Първо напред: h1 = 0,8808, h2 = 0,8176, p = 0,4489.
  3. Грешката: (0,4489 − 0)² = 0,2015. Мрежата трябва да каже по-малко.
  4. Назад от изхода: делът е 2(p − y) · p(1 − p) = 0,8978 × 0,2474 = 0,2221, и наклонът за c.
  5. Наклонът за v[j] е делът по h[j]: 0,2221 × 0,8808 = 0,1956 и 0,2221 × 0,8176 = 0,1816.
  6. Назад по v и през σ: делът на h1 е 0,2221 × 1,5 × 0,1050 = 0,0350, на h2 е −0,0497. Те са и наклоните за b.
  7. Наклонът за w[j][i] е делът на h[j] по входа x[i]. И двата входа са 1: 0,0350 и −0,0497.
  8. Девет наклона от едно минаване назад, същите като с побутването. Тегло с положителен наклон слиза.

Напред минават стойностите, назад минават наклоните. Всеки наклон на ребро е делът на неврона, към който води реброто, по стойността, която е влязла по него.

Пробвай

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

Делът на изхода е 0,4, теглото към изхода е 1,5, а скритият неврон е дал 0,5. Какво ще отпечата: наклонът за това тегло и делът на скрития неврон?

double dOut = 0.4; // делът на изхода
double v = 1.5;
double h = 0.5;
double dH = dOut * v * h * (1 - h);
System.out.printf("%.2f %.2f%n", dOut * h, dH);
Отговор

Пробвай

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

Колега пише backward сам. Мрежата учи, но бавно и странно. Проверката с побутване показва, че наклоните на изхода съвпадат, а на скрития слой са от 7 до 10 пъти по-големи. На кой ред е грешката?

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

Виж го

Една стъпка на ученето

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

static double meanLoss(Net net, double[][] cases, double[] labels) {
double s = 0;
for (int 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};
// средният наклон за четирите плащания
Net mean = Net.zeros();
for (int n = 0; n < 4; n++) {
Net g = backward(net, cases[n], labels[n]);
for (int k = 0; k < 9; k++) {
set(mean, k, get(mean, k) + get(g, k) / 4);
}
}
double rate = 2.0; // скоростта на учене
double before = meanLoss(net, cases, labels);
for (int k = 0; k < 9; k++) {
double next = get(net, k) - rate * get(mean, k);
System.out.printf("%-8s%8.4f → %.4f%n", NAMES[k], get(net, k), next);
set(net, k, next);
}
double after = meanLoss(net, cases, labels);
System.out.printf("средна грешка: %.4f → %.4f%n", before, after);
Изход
w[0][0] 1.5000 → 1.5147
w[0][1] 1.0000 → 1.0210
w[1][0] 2.5000 → 2.4863
w[1][1] 2.0000 → 1.9926
b[0] -0.5000 → -0.4947
b[1] -3.0000 → -3.0367
v[0] 1.5000 → 1.4987
v[1] -1.5000 → -1.5266
c -0.3000 → -0.3285
средна грешка: 0.2236 → 0.2218

Средната грешка пада от 0,2236 на 0,2218. Малко, но в правилната посока. Обучението е същата стъпка повторена хиляди пъти: проход напред, проход назад, стъпка. Целият цикъл и как пада грешката е следващият урок.

Пробвай

Попълни

Каква е стъпката за всяко число на мрежата?

for (int 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.0481
w[0]: backprop -0.1727, побутване -0.1727
w[1]: backprop -0.0751, побутване -0.0751
w[2]: backprop -0.0751, побутване -0.0751
b: backprop -0.0751

Наклоните са отрицателни: по-големи тегла дават по-голямо p, по-близо до 1, и грешката пада. Теглото на сумата получава най-много вина, защото сумата е най-големият вход (2,3).

Още задачи

  • Стъпка. Направи една стъпка със скорост 0,5 и отпечатай новото p. То трябва да е по-близо до 1.
  • Друго плащане. Пусни същото за плащането от 40 €, което е наред (y = 0). Кои наклони стават нула и защо?

Тест

Провери се

1. Какво казва наклонът на грешката за едно тегло?
2. Защо backprop, а не побутване на всяко тегло?
3. Делът на скрития неврон j е dOut × v[j] × h[j] × (1 − h[j]). Защо има v[j]?
4. Входът x[i] е 0. Какъв е наклонът за теглото w[j][i]?
5. Backprop е написан с грешка, а мрежата пак учи малко. Как го хващаш?

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