Обучението от начало до край
Накратко: Обучението на невронна мрежа е цикъл по епохи: разбъркване, партиди, проход напред, обратно разпространение и стъпка, докато грешката на валидацията спре да пада.
Проблем
Мрежата казва −0,23 дни
Страницата за плащане трябва да казва „пристига до 2 дни“. Времето на доставката зависи от разстоянието, от това дали поръчката е след 16:00 (тогава тръгва на другия ден) и малко от теглото. Права линия от линейната регресия не следва тази крива: в града е около ден, до 200 km расте бързо, после все по-бавно.
Мрежата от урока за backprop може да следва крива. Ето я с 4 скрити неврона и случайни тегла, преди обучение:
rng = Rng(42)net = Net(4, 3)net.randomize(rng, 1.0)print(f"грешка (MSE): {net.loss(train_x, train_y):.4f}")for k in [0, 12, 23]: print(f"{TRAIN[k][0]} km: мрежата казва {net.predict(train_x[k]):.2f}, " f"истина {train_y[k]:.1f} дни")грешка (MSE): 7.18555 km: мрежата казва -0.23, истина 1.0 дни150 km: мрежата казва -0.17, истина 2.3 дни520 km: мрежата казва 0.18, истина 3.0 дниПоръчка на 5 km пристига след −0,23 дни. В урока за backprop видя една стъпка за един пример. Колко стъпки трябват, с кои примери, в какъв ред и кога да спреш?
Идеята
Цикълът на обучение
Обучението е един цикъл, който се върти, докато мрежата спре да се подобрява:
- Начални тегла: малки случайни числа от генератор със семе.
- Епоха: едно минаване през всички примери за обучение. Преди всяка епоха примерите се разбъркват, за да не учи мрежата реда им.
- Партида: няколко примера наведнъж. За всеки има проход напред и грешка, после назад. Наклоните се усредняват и мрежата прави една стъпка.
- Проверка: на всеки 100 епохи програмата печата грешката на обучението и на валидацията.
Това е цикълът на обучение (training loop). Числата, които печата, нарисувани като графика, са кривата на грешката. По нея разбираш дали мрежата учи, дали скоростта е добра и кога да спреш.
Данните са 32 поръчки от магазина: 24 за обучение и 8 за валидация. Мрежата никога не прави стъпка с поръчките за валидация. Те са мярката за нови поръчки.
Виж го
Данните и мрежата
Поръчките, записани в кода:
# поръчка: km, след 16:00 (1 или 0), kg, дни до доставкатаTRAIN = [ (5, 0, 1.2, 1.0), (12, 1, 3.5, 1.6), (18, 0, 0.8, 1.0), (25, 0, 6.0, 1.2), (28, 1, 2.1, 1.8), (35, 0, 4.4, 1.3), (48, 1, 0.5, 2.2), (60, 0, 9.0, 2.5), (75, 0, 1.7, 1.6), (90, 1, 2.9, 2.6), (110, 0, 5.5, 1.9), (130, 1, 0.9, 2.7), (150, 0, 3.1, 2.3), (170, 0, 8.2, 3.3), (185, 1, 1.4, 3.1), (195, 0, 2.6, 2.3), (210, 0, 4.8, 2.6), (240, 1, 0.6, 3.3), (280, 0, 8.6, 3.6), (320, 0, 1.9, 2.7), (360, 1, 3.3, 3.6), (410, 0, 5.0, 2.8), (460, 1, 2.2, 3.6), (520, 0, 0.7, 3.0),]# валидация: поръчки, които мрежата не вижда при обучениетоVALID = [ (8, 1, 2.4, 1.6), (22, 0, 8.8, 1.0), (40, 0, 1.1, 1.4), (95, 1, 7.9, 2.5), (160, 0, 2.0, 2.3), (205, 1, 3.9, 3.1), (300, 0, 9.4, 2.6), (480, 1, 4.6, 3.6),]Признаците се свеждат до близки мащаби: километрите в стотици, теглото в десетки килограми. Иначе 520 km щяха да заглушат всичко останало:
# признаците в близки мащаби: стотици km, 0 или 1, десетки kgdef inputs(orders): return [[km / 100, late, kg / 10] for km, late, kg, _ in orders]
def targets(orders): return [days for *_, days in orders]Случайността идва от собствен генератор със семе. Едно семе дава едни и същи числа на всеки компютър и на четирите езика, затова урокът печата един и същ изход навсякъде. Числото 48271 и делението на 2³¹ − 1 са стар и проверен генератор (Park–Miller). Произведението е под 2⁵³, затова се смята точно дори в JavaScript:
class Rng: def __init__(self, seed): self.state = seed
# следващото число между 0 и 1; едно семе дава едни и същи числа def next(self): self.state = self.state * 48271 % 2147483647 return self.state / 2147483647random.seed(42) в Python също дава повторими числа. Тук генераторът е наш, за да е същият като в другите три езика.Мрежата е тази от урока за backprop, събрана в клас: 3 входа, 4 скрити неврона със сигмоида и един изход без активация, защото дните не са вероятност. step прави проход напред и назад за всяка поръчка от партидата, събира наклоните и мести всяко тегло веднъж, със средния наклон:
def sigmoid(z): return 1 / (1 + math.exp(-z))
class Net: def __init__(self, hidden, inputs): self.w1 = [[0.0] * inputs for _ in range(hidden)] # вход → скрит слой self.b1 = [0.0] * hidden self.w2 = [0.0] * hidden # скрит слой → изход self.b2 = 0.0 self.h = [0.0] * hidden # изходите на скрития слой при последното predict
# случайни тегла от -scale до scale; отместванията остават 0 def randomize(self, rng, scale): for row in self.w1: for i in range(len(row)): row[i] = (rng.next() * 2 - 1) * scale for j in range(len(self.w2)): self.w2[j] = (rng.next() * 2 - 1) * scale
def predict(self, x): y = self.b2 for j, row in enumerate(self.w1): z = self.b1[j] for i in range(len(x)): z += row[i] * x[i] self.h[j] = sigmoid(z) y += self.w2[j] * self.h[j] return y
def loss(self, xs, ys): total = 0.0 for x, y in zip(xs, ys): e = self.predict(x) - y total += e * e return total / len(xs)
# една стъпка: средният наклон по примерите от партидата def step(self, xs, ys, batch, rate): hidden, inputs = len(self.w2), len(xs[0]) gw1 = [[0.0] * inputs for _ in range(hidden)] gb1, gw2, gb2 = [0.0] * hidden, [0.0] * hidden, 0.0 for k in batch: d = 2 * (self.predict(xs[k]) - ys[k]) gb2 += d for j in range(hidden): gw2[j] += d * self.h[j] dz = d * self.w2[j] * self.h[j] * (1 - self.h[j]) gb1[j] += dz for i in range(inputs): gw1[j][i] += dz * xs[k][i] n = len(batch) self.b2 -= rate * gb2 / n for j in range(hidden): self.w2[j] -= rate * gw2[j] / n self.b1[j] -= rate * gb1[j] / n for i in range(inputs): self.w1[j][i] -= rate * gw1[j][i] / nРазбъркването е Fisher–Yates: отзад напред всяко място се разменя със случайно място преди него.
# Fisher–Yates: всяка подредба е еднакво вероятнаdef shuffle(order, rng): for i in range(len(order) - 1, 0, -1): k = int(rng.next() * (i + 1)) order[i], order[k] = order[k], order[i]Виж го
Цикълът и кривата на грешката
rng = Rng(42)net = Net(4, 3)net.randomize(rng, 1.0)size = 8rate = 0.05order = list(range(len(train_x)))print("епоха обучение валидация")for epoch in range(1, 1001): shuffle(order, rng) for start in range(0, len(order), size): batch = order[start:start + size] net.step(train_x, train_y, batch, rate) if epoch % 100 == 0: t = net.loss(train_x, train_y) v = net.loss(valid_x, valid_y) print(f"{epoch:5d}{t:10.4f}{v:11.4f}")Стъпка по стъпка, с думи
- Семе 42: всяко пускане започва от едни и същи случайни тегла. Преди обучението грешката е 7,19, далеч над графиката.
- Епоха е едно минаване през всичките 24 поръчки за обучение. Първо се разбъркват, със същия генератор.
- Партида от 8 поръчки: напред, грешка, назад, една стъпка. Три стъпки на епоха, 3000 за цялото обучение.
- Епоха 100: обучение 0,1365, валидация 0,0756. С поръчките за валидация не е направена нито една стъпка.
- Валидацията е най-ниска на епоха 200: 0,0705. После тръгва нагоре, а обучението продължава да пада.
- Епоха 1000: обучение 0,0381, валидация 0,2371. Мрежата наизустява три тежки пратки, закъснели по случайност.
- Най-добрата мрежа е около епоха 200, не на 1000. Спира се, когато валидацията спре да пада.
Целият цикъл: 1000 епохи, партиди по 8, скорост 0,05:
rng = Rng(42)net = Net(4, 3)net.randomize(rng, 1.0)size = 8rate = 0.05order = list(range(len(train_x)))print("епоха обучение валидация")for epoch in range(1, 1001): shuffle(order, rng) for start in range(0, len(order), size): batch = order[start:start + size] net.step(train_x, train_y, batch, rate) if epoch % 100 == 0: t = net.loss(train_x, train_y) v = net.loss(valid_x, valid_y) print(f"{epoch:5d}{t:10.4f}{v:11.4f}")епоха обучение валидация 100 0.1365 0.0756 200 0.1041 0.0705 300 0.0826 0.0776 400 0.0667 0.1048 500 0.0556 0.1350 600 0.0486 0.1718 700 0.0443 0.1967 800 0.0417 0.2202 900 0.0396 0.2243 1000 0.0381 0.2371Обучението пада от 7,19 до 0,0381. Валидацията пада до епоха 200, а после се качва: на епоха 1000 е 0,2371, повече от три пъти по-лоша от най-добрата. Мрежата продължава да се нагажда към поръчките за обучение, включително към това, което в тях е случайност. Към това ще се върнем в края на урока.
Пробвай
Предвиди изхода
Ако партидата е 10, а примерите са 24, колко стъпки прави обучението за 1000 епохи?
examples, size, steps = 24, 10, 0for epoch in range(1, 1001): for start in range(0, examples, size): steps += 1print(f"стъпки: {steps}")24 примера по 10 са три партиди: 10, 10 и 4. Последната е по-малка, но е стъпка като другите, затова са 3 на епоха и 3000 общо. В step средният наклон е по броя примери в партидата, така че и малката партида прави стъпка с нормален размер.
Идеята
Партиди: всички, по един или по 8
Колко примера да влизат в една стъпка? Ето същата мрежа за 40 епохи с партида 24 (всички наведнъж), 8 и 1. След всяка стъпка програмата мери грешката на всички 24 поръчки и брои колко пъти стъпката я е вдигнала:
for size in [24, 8, 1]: rng = Rng(42) net = Net(4, 3) net.randomize(rng, 1.0) order = list(range(len(train_x))) steps, up = 0, 0 before = net.loss(train_x, train_y) for epoch in range(1, 41): shuffle(order, rng) for start in range(0, len(order), size): net.step(train_x, train_y, order[start:start + size], 0.05) after = net.loss(train_x, train_y) steps += 1 if after > before: up += 1 before = after print(f"партида {size:2d}: {steps:3d} стъпки, {up:3d} нагоре, грешка {before:.4f}")партида 24: 40 стъпки, 0 нагоре, грешка 0.2913партида 8: 120 стъпки, 21 нагоре, грешка 0.1840партида 1: 960 стъпки, 495 нагоре, грешка 0.1238- Всички наведнъж: наклонът е точният наклон на цялата грешка, затова нито една стъпка не я вдига. Но стъпката е една на епоха, и след 40 епохи мрежата е най-далеч.
- По един пример: 24 стъпки на епоха, затова за 40 епохи стига най-ниско. Но наклонът на един пример е груба догадка за наклона на всички: 495 от 960 стъпки вдигат грешката.
- Партида от 8: три стъпки на епоха и само 21 от 120 нагоре.
Това се казва mini-batch (партида): стъпка по средния наклон на няколко примера. Партидата е компромисът, който ползва почти всеки. На видеокарта примерите от една партида се смятат наведнъж, затова в истински проекти партидите са десетки или стотици примери.
Пробвай
Попълни
Наклоните на партидата се събират, а стъпката е по средния наклон. На какво се дели?
n =
self.b2 -= rate * gb2 / nНа броя примери в партидата: len(batch). Без делението партида от 24 прави 24 пъти по-голяма стъпка от партида от 1, и смяната на партидата сменя и скоростта на учене.
Идеята
Лоша скорост, лошо начало
Кривата казва какво не е наред. Трите скорости от урока за градиентното спускане, но в мрежа. Грешката е на епоха 50, 100 и 200:
for rate in [0.005, 0.05, 2.0]: rng = Rng(42) net = Net(4, 3) net.randomize(rng, 1.0) order = list(range(len(train_x))) line = f"скорост {rate:.3f}:" for epoch in range(1, 201): train_epoch(net, train_x, train_y, order, 8, rate, rng) loss = net.loss(train_x, train_y) if math.isnan(loss) or loss > 1e6: line += f" разходи се на епоха {epoch}" break if epoch in (50, 100, 200): line += f" {loss:.4f}" print(line)скорост 0.005: 0.3892 0.3201 0.2472скорост 0.050: 0.1698 0.1365 0.1041скорост 2.000: разходи се на епоха 2С 0,005 мрежата учи, но бавно: на епоха 200 (0,2472) е по-зле, отколкото 0,05 на епоха 50 (0,1698). Кривата пада полегато и не спира да пада, а това значи, че можеш да вдигнеш скоростта. С 2 всяка стъпка прескача дъното и следващият наклон е още по-стръмен. Грешката е над милион още на втората епоха, а след нея идват безкрайност и NaN. Затова програмата проверява и спира.
Началото също има значение:
rng = Rng(42)zeros = Net(4, 3) # без randomize: всички тегла са 0order = list(range(len(train_x)))for epoch in range(1, 201): train_epoch(zeros, train_x, train_y, order, 8, 0.05, rng)print(f"нули: грешка {zeros.loss(train_x, train_y):.4f}")for j, row in enumerate(zeros.w1): print(f" неврон {j + 1}: " + " ".join(f"{x:.2f}" for x in row))
for scale in [8, 1]: rng = Rng(42) net = Net(4, 3) net.randomize(rng, scale) first = net.loss(train_x, train_y) order = list(range(len(train_x))) for epoch in range(1, 201): train_epoch(net, train_x, train_y, order, 8, 0.05, rng) print(f"тегла ×{scale}: в началото {first:.4f}, след 200 епохи {net.loss(train_x, train_y):.4f}")нули: грешка 0.1127 неврон 1: 0.99 0.47 0.19 неврон 2: 0.99 0.47 0.19 неврон 3: 0.99 0.47 0.19 неврон 4: 0.99 0.47 0.19тегла ×8: в началото 33.9817, след 200 епохи 0.2971тегла ×1: в началото 7.1855, след 200 епохи 0.1041- Нули. Четирите скрити неврона започват еднакви, получават еднакви наклони и правят еднакви стъпки. След 200 епохи теглата им са до последната цифра едни и същи: мрежата е с един скрит неврон, повторен четири пъти. Случайните начални тегла са там, за да се различат невроните.
- Тегла ×8. Сумите в сигмоидата са големи, затова при повечето поръчки тя е почти 0 или почти 1, там, където е плоска. Плоска крива значи почти нулев наклон и малки стъпки: след 200 епохи грешката е почти три пъти по-голяма, отколкото с теглата ×1.
Пробвай
Намери грешката
Колега пише цикъла по свой начин и вместо номерата разбърква самите масиви. Грешката стои над 0,6 и не пада, колкото и епохи да пусне. На кой ред е грешката?
Избери реда с грешката.
Двете разбърквания взимат различни случайни числа от генератора, затова след тях поръчките и отговорите вече не са в един ред. Мрежата учи например поръчка на 5 km с отговора на поръчка на 410 km. Най-доброто, което може, е да казва средното за всички, и грешката стои около разсейването на отговорите. Поправката е като в урока: разбърквай номерата (order) и взимай поръчката и отговора с един и същ номер.
Идеята
Кога да спреш
Какво точно наизустява мрежата? Сред поръчките за обучение има три тежки пратки (9,0 kg, 8,2 kg и 8,6 kg), които закъсняха с цял ден по случайност. Ето трите тежки пратки от валидацията, които не са закъснели:
rng = Rng(42)net = Net(4, 3)net.randomize(rng, 1.0)order = list(range(len(train_x)))for epoch in range(1, 1001): train_epoch(net, train_x, train_y, order, 8, 0.05, rng) if epoch in (200, 1000): print(f"епоха {epoch}:") for k in [1, 3, 6]: # тежките пратки от валидацията km, _, kg, days = VALID[k] print(f" {km} km, {kg:.1f} kg: {net.predict(valid_x[k]):.2f} (истина {days:.1f})")епоха 200: 22 km, 8.8 kg: 1.31 (истина 1.0) 95 km, 7.9 kg: 2.53 (истина 2.5) 300 km, 9.4 kg: 3.13 (истина 2.6)епоха 1000: 22 km, 8.8 kg: 1.84 (истина 1.0) 95 km, 7.9 kg: 3.09 (истина 2.5) 300 km, 9.4 kg: 3.38 (истина 2.6)На епоха 200 предсказанията са близо до истината. На епоха 1000 мрежата е научила „тежкото пътува по-бавно“: пратката на 22 km получава 1,84 дни вместо 1,0. Правило, което в данните е само случайност, е преобучение: обучението се подобрява, а новите поръчки се влошават.
Затова се спира, когато валидацията спре да пада. Това е ранно спиране (early stopping). Програмата проверява валидацията на всеки 10 епохи и пази снимка (копие) на най-добрата мрежа. Ако 100 епохи няма подобрение, спира и връща снимката:
# копие на теглата: снимка на мрежата в този моментdef copy(self): c = Net(len(self.w2), len(self.w1[0])) c.w1 = [row[:] for row in self.w1] c.b1, c.w2, c.b2 = self.b1[:], self.w2[:], self.b2 return crng = Rng(42)net = Net(4, 3)net.randomize(rng, 1.0)order = list(range(len(train_x)))# проверка на всеки 10 епохи; спираме след 100 без подобрениеbest, best_epoch, epoch = math.inf, 0, 0best_net = net.copy()while epoch < 1000 and epoch - best_epoch < 100: epoch += 1 train_epoch(net, train_x, train_y, order, 8, 0.05, rng) if epoch % 10 == 0: valid = net.loss(valid_x, valid_y) if valid < best: best, best_epoch = valid, epoch best_net = net.copy()print(f"най-ниска валидация: {best:.4f} на епоха {best_epoch}")print(f"спрях на епоха {epoch}")print(f"пазя мрежата от епоха {best_epoch}: {best_net.loss(valid_x, valid_y):.4f}")най-ниска валидация: 0.0667 на епоха 230спрях на епоха 330пазя мрежата от епоха 230: 0.0667Най-добрата валидация е 0,0667 на епоха 230, по-добре от всяка точка на кривата горе, защото проверката е по-честа. Обучението спира на епоха 330 вместо 1000, и мрежата, която се пази, е от епоха 230.
Валидацията реши кога да спреш, затова вече не е честна мярка за новите поръчки. Окончателната оценка се прави на трета част от данните, теста, който не е участвал в нито едно решение.
Идеята
Кога вреди
- Мрежа за всичко. 32 поръчки и три признака са малко данни: права с няколко признака или по-проста формула често стигат, обясняват се по-лесно и не наизустяват. Мрежата си струва, когато правилата са твърде много, за да ги напишеш, и има достатъчно примери.
- Едно пускане. Друго семе дава други начални тегла и друга крива. Едно пускане може да е късмет. Сравнявай промени с няколко семена и гледай средното.
- Малка валидация. Осем поръчки са шумна мярка: една странна поръчка мести кривата. Колкото по-малко са данните, толкова по-внимателно четеш една точка.
- Ранното спиране не поправя данните. Трите случайни закъснения още са там. По-добре е да ги намериш и да ги махнеш или поправиш, а ако теглото наистина не влияе, да не го даваш като признак.
- Цената. Тук обучението е под секунда. Истинските мрежи се учат часове или седмици на много видеокарти. Клъстерите за обучение и пускането на модел в работа са инфраструктура и се учат в курса по DevOps ZeroToOps.
По-точно, за любопитните
Стъпката с формула. Партидата е B примера, а η е скоростта. За всяко тегло w:
w ← w − η · (1/B) · Σ ∂L/∂w
С думи: събери наклона на грешката по w за всеки пример от партидата, раздели на B, умножи по скоростта и извади. ∂L/∂w е наклонът, който backprop смята за едно тегло. При B, равно на всички примери, това е обикновеното градиентно спускане. При B = 1 се казва стохастично (SGD), а с партиди е mini-batch SGD.
Защо 2 · (y − t). Грешката на един пример е (y − t)². Наклонът ѝ по y е 2 · (y − t): ако изходът е с 0,5 над истината, наклонът е 1, а ако е с 0,5 под нея, е −1. Оттам започва backprop в step.
Истинските инструменти. Практиката на машинното обучение е главно на Python. В PyTorch същият цикъл е: DataLoader(..., batch_size=8, shuffle=True) дава партидите, optimizer.zero_grad() нулира наклоните (нашите нови масиви в началото на стъпката), loss.backward() е backprop, а optimizer.step() мести теглата. Семето е torch.manual_seed(42). В C# същото прави ML.NET или TorchSharp, в Java DJL или Tribuo, а в JavaScript TensorFlow.js, ONNX Runtime пуска вече научен модел. Цикълът е същият, само че матриците се смятат на видеокарта.
По-умни стъпки. Почти никой не ползва голата стъпка от урока. Momentum помни посоката на последните стъпки и минава по-гладко през шума на партидите. Adam пази отделна скорост за всяко тегло. Скоростта често се сваля с времето (learning rate schedule): големи стъпки в началото, малки накрая.
Началните тегла. Тук случайните тегла са от −1 до 1. В по-големи мрежи интервалът се смята по броя входове на неврона, например ±1/√n (Xavier, He), за да не са сумите твърде големи още в първия слой.
Еднакви числа на четирите езика. Събиранията и умноженията тук вървят в един и същ ред, затова резултатът е един и същ бит по бит. exp не е обещано, че е еднакъв до последния бит на всеки език, затова програмите печатат по 4 знака. Изходът е проверен и е един и същ на Java, C#, Python и JavaScript.
f"{x:.4f}" винаги пише точка, независимо от езика на системата.Упражнение
Ранно спиране при три скорости
Реши задачата в редактора си.
Вземи ранното спиране от урока и го пусни за скорости 0,02, 0,05 и 0,2, всеки път с нова мрежа от семе 42. За всяка скорост отпечатай най-ниската валидация, епохата ѝ и епохата, на която обучението спира. Не е нужно да пазиш копие на мрежата.
скорост 0.02: най-ниска валидация 0.0683 на епоха 470, спрях на епоха 570скорост 0.05: най-ниска валидация 0.0667 на епоха 230, спрях на епоха 330скорост 0.20: най-ниска валидация 0.0628 на епоха 50, спрях на епоха 150С 0,2 мрежата стига най-ниската си валидация за 50 епохи и спира на 150. С 0,02 ѝ трябват 470. По-голямата скорост стига дъното по-бързо, но и тръгва нагоре по-рано. Затова валидацията се проверява често, а не само в края.
Още задачи
- Шест неврона. Пусни основния цикъл с 6 скрити неврона. По-рано ли тръгва нагоре валидацията?
- Без теглото. Махни теглото от признаците (2 входа вместо 3) и пусни основния цикъл. Какво става с валидацията на епоха 1000?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Преобучение: моделът, който наизустява · Неврон · Слоеве · Обратно разпространение
- Продължава в: Цифрите от касовата бележка
- Задачи по темата: Невронна мрежа отвътре