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

Преобучение: моделът, който наизустява

Накратко: Преобучението (overfitting) е, когато моделът наизустява обучаващите данни и греши на нови; валидацията го хваща, а по-прост модел и регуляризация го лекуват.

Проблем

Перфектният модел, който обещава 5 минути

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

Колега пробва два модела върху 8 минали поръчки. Правата от линейната регресия греши с няколко минути. Неговата крива не греши нито веднъж:

line = fit(train, 1, 0)
curve = fit(train, 7, 0)
print(f"права (степен 1): MSE на обучението {mse(line, train):.2f}")
print(f"крива (степен 7): MSE на обучението {mse(curve, train):.2f}")
for km in [3.5, 21.5]:
print(f"поръчка {km:.1f} км: права {predict(line, km):.0f} мин, крива {predict(curve, km):.0f} мин")
Изход
права (степен 1): MSE на обучението 9.35
крива (степен 7): MSE на обучението 0.00
поръчка 3.5 км: права 24 мин, крива 5 мин
поръчка 21.5 км: права 70 мин, крива 79 мин

Кривата е с MSE 0,00: перфектна върху миналото. После идва нова поръчка на 3,5 км. Кривата обещава 5 минути, правата 24. Истинското време се оказва 22 минути. За поръчка на 21,5 км кривата казва 79, правата 70, а истината е 68.

Моделът, който никога не греши върху миналото, е най-лош за бъдещето. Това е преобучение (overfitting): моделът е наизустил примерите, заедно с шума им, вместо да научи зависимостта.

Виж го

Кривата от степен d

Правата е w₀ + w₁ · x. Кривата добавя степени на x: w₀ + w₁ · x + w₂ · x² + … + w₇ · x⁷. Това е полином от степен 7, с 8 тегла. Степен 1 е правата.

Поръчките са примерни, но наподобяват истински: по-далеч е по-дълго, с няколко минути шум (трафик, асансьор, чакане на вратата). Освен 8-те за обучение има 8 други за валидация и 8 за теста. Защо три групи, е в следващата карта.

# обучение: км до клиента и минути до вратата
train = [(2, 19), (5, 23), (7, 38), (10, 42), (13, 52), (16, 54), (19, 63), (22, 69)]
# валидация: други поръчки, само за избора
val = [(3, 24), (6.5, 31), (9, 36), (12, 44), (15, 52), (18, 57), (21, 70), (4, 27)]
# тест: пуска се веднъж, накрая
test = [(3.5, 22), (8, 34), (11, 38), (14, 51), (17, 59), (20, 63), (6, 30), (21.5, 68)]

x е разстоянието, преместено около 12 км и смалено десет пъти. Иначе 22⁷ е около 2,5 милиарда и сметките губят точност.

# x е разстоянието около 12 км, смалено десет пъти
def powers(km, degree):
x = (km - 12) / 10
p = [1.0]
for j in range(1, degree + 1):
p.append(p[j - 1] * x)
return p
def predict(w, km):
p = powers(km, len(w) - 1)
total = 0.0
for j in range(len(w)):
total += w[j] * p[j]
return total
def mse(w, data):
total = 0.0
for km, minutes in data:
e = predict(w, km) - minutes
total += e * e
return total / len(data)

Теглата можеш да намериш с градиентно спускане, но при степен 7 то върви милиони стъпки. Има пряк път: най-добрите тегла са решението на система уравнения, с толкова уравнения, колкото са теглата. Системата се решава с метода на Гаус, като в училище, само че с 8 неизвестни. Кодът е тук, за да е видимо всичко. Не е нужно да го следиш ред по ред:

# най-добрите тегла направо: система уравнения
def fit(data, degree, lam):
m = degree + 1
a = [[0.0] * m for _ in range(m)]
b = [0.0] * m
for km, minutes in data:
p = powers(km, degree)
for i in range(m):
b[i] += p[i] * minutes
for j in range(m):
a[i][j] += p[i] * p[j]
# наказанието: λ по диагонала, без свободния член
for i in range(1, m):
a[i][i] += lam
return solve(a, b)
# методът на Гаус: решава a · w = b
def solve(a, b):
n = len(b)
for c in range(n):
p = c
for r in range(c + 1, n):
if abs(a[r][c]) > abs(a[p][c]):
p = r
a[c], a[p] = a[p], a[c]
b[c], b[p] = b[p], b[c]
for r in range(c + 1, n):
f = a[r][c] / a[c][c]
for k in range(c, n):
a[r][k] -= f * a[c][k]
b[r] -= f * b[c]
x = [0.0] * n
for r in range(n - 1, -1, -1):
s = b[r]
for k in range(r + 1, n):
s -= a[r][k] * x[k]
x[r] = s / a[r][r]
return x

Редът с наказанието засега получава 0. Ще стигнем до него.

Идеята

Обучение, валидация и тест

В урока за оценката данните бяха две групи: обучение и тест. Сега трябва и трета. Ето защо: искаме да изберем степента. Ако я избираме по грешката на обучението, винаги печели 7, защото тя е 0. Ако я избираме по теста, тестът вече участва в решение и не е честен накрая.

Затова данните са три групи:

  • обучение (training set): от него се учат теглата;
  • валидация (validation set): по нея се сравняват моделите и се избира степента;
  • тест (test set): пуска се веднъж, накрая, за избрания модел.
print("MSE: обучение / валидация")
best, best_val = 1, float("inf")
for degree in range(1, 8):
w = fit(train, degree, 0)
train_err = mse(w, train)
val_err = mse(w, val)
print(f"степен {degree}: "
f"{train_err:.2f} / {val_err:.2f}")
if val_err < best_val:
best_val = val_err
best = degree
test_err = mse(fit(train, best, 0), test)
print(f"избрана степен: {best}, "
f"тест: {test_err:.2f}")
Изход
MSE: обучение / валидация
степен 1: 9.35 / 3.64
степен 2: 6.74 / 9.35
степен 3: 6.61 / 9.09
степен 4: 5.64 / 11.95
степен 5: 3.75 / 18.81
степен 6: 3.30 / 29.06
степен 7: 0.00 / 121.20
избрана степен: 1, тест: 5.57

Грешката на обучението пада с всяка степен: 9,35, 6,74, …, 0,00. Грешката на валидацията расте: 3,64, 9,35, …, 121,20. Колкото повече тегла има кривата, толкова по-добре помни 8-те точки и толкова по-зле познава нови. Валидацията избира степен 1, правата, а тестът ѝ е 5,57 (корен от 5,57 е около 2,4 минути средна грешка).

Едно нещо изглежда странно: правата е с по-малка грешка на валидацията (3,64), отколкото на обучението (9,35). При 8 поръчки случайността е голяма: валидацията просто е попаднала на поръчки, по-близки до правата. Затова при малко данни не се доверявай на втория знак.

Виж го

Кривата се гърчи

print("MSE: обучение / валидация")
best, best_val = 1, float("inf")
for degree in range(1, 8):
w = fit(train, degree, 0)
train_err = mse(w, train)
val_err = mse(w, val)
print(f"степен {degree}: "
f"{train_err:.2f} / {val_err:.2f}")
if val_err < best_val:
best_val = val_err
best = degree
test_err = mse(fit(train, best, 0), test)
print(f"избрана степен: {best}, "
f"тест: {test_err:.2f}")
Стъпка по стъпка, с думи
  1. 8 поръчки за обучение: разстоянието в км и минутите до вратата. По-далеч е по-дълго, с малко шум.
  2. Степен 1 е права. Пропуска повечето точки с няколко минути: MSE на обучението 9,35.
  3. Всяка степен добавя едно тегло. Кривата се огъва повече и се доближава до всяка точка от обучението.
  4. Степен 7 има 8 тегла за 8 точки и минава през всяка: MSE на обучението 0,00. Изглежда перфектно.
  5. Сега 8 други поръчки, за валидация. Между точките на обучението кривата отскача: MSE на валидацията 121,20.
  6. За всяка степен: грешката на обучението пада, на валидацията расте. Валидацията избира степен 1, правата.
  7. Друг лек: степен 7, но с наказание за големи тегла. С λ = 0,1 кривата се успокоява: MSE на валидацията 9,55.
  8. Кривата, която наизусти обучението, е най-лоша за нови поръчки. Тестът: 5,57 за правата, 9,96 за кривата с λ = 0,1.

Кривата от степен 7 уцелва всяка синя точка и пропуска оранжевите с десетки минути. Стълбчетата отдолу казват същото за всяка степен: синьото (обучението) пада, оранжевото (валидацията) расте.

Пробвай

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

Колега избира степента така. Програмата му винаги избира 7. На кой ред е грешката?

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

Идеята

Три лека

1. По-прост модел. Валидацията вече го избра: правата. Когато зависимостта е почти права, правата е достатъчна, а всяко тегло отгоре е място за шум.

2. Повече данни. Кривата от степен 7 има 8 тегла. С 8 точки може да мине точно през всяка. С 16 точки не може, затова трябва да мине между тях, а това изглажда шума:

both = train + val
for data in [train, both]:
print(f"степен 7, {len(data)} поръчки: тест {mse(fit(data, 7, 0), test):.2f}")
Изход
степен 7, 8 поръчки: тест 84.44
степен 7, 16 поръчки: тест 9.41
train + val е нов списък с редовете на двата.

Тестът пада от 84,44 на 9,41. Тук за обучението ползваме и валидацията, затова изборът на степен вече няма с какво да се направи. В истински проект за това има кръстосана валидация (виж „По-точно“).

3. Наказание за големи тегла. Гърчещата се крива има огромни тегла: най-голямото е 571,59. Кривата уцелва всяка точка, защото огромни тегла с различни знаци почти се унищожават, а остатъкът от тях гърчи кривата. Регуляризацията добавя към грешката наказание: λ по сбора на квадратите на теглата. Сега обучението търси тегла, които и уцелват точките, и са малки. В кода това е единственият ред с наказанието във fit. λ се избира с валидацията, както степента:

# най-голямото тегло без свободния член
def biggest(w):
return max(abs(x) for x in w[1:])
print("обучение / валидация, max |w|")
best_lam, best_val = 0, float("inf")
for lam in [0, 0.001, 0.01, 0.1, 1, 10]:
w = fit(train, 7, lam)
train_err = mse(w, train)
val_err = mse(w, val)
print(f"λ {lam:.3f}: {train_err:.2f} / "
f"{val_err:.2f}, {biggest(w):.2f}")
if val_err < best_val:
best_val = val_err
best_lam = lam
test_err = mse(fit(train, 7, best_lam), test)
print(f"избрано λ: {best_lam:.3f}, "
f"тест: {test_err:.2f}")
Изход
обучение / валидация, max |w|
λ 0.000: 0.00 / 121.20, 571.59
λ 0.001: 2.80 / 26.49, 36.79
λ 0.010: 3.59 / 17.08, 19.16
λ 0.100: 5.72 / 9.55, 20.21
λ 1.000: 19.95 / 16.71, 13.35
λ 10.000: 100.67 / 93.35, 4.92
избрано λ: 0.100, тест: 9.96

Последното число на всеки ред (max |w|) е най-голямото тегло по абсолютна стойност, без свободния член. С λ = 0,1 най-голямото тегло пада от 571,59 до 20,21, а валидацията от 121,20 до 9,55. Твърде силното наказание (λ = 10) смачква кривата почти до хоризонтална линия: грешката става голяма и на обучението (100,67). Това е обратното на преобучение, недообучение: моделът е твърде ограничен, за да хване зависимостта.

Тестът на избраното λ е 9,96: много по-добре от 84,44, но правата (5,57) пак е най-добра. Регуляризацията не прави от крива права, тя само не ѝ дава да полудее.

Пробвай

Попълни

Наказанието отива по диагонала на системата, без свободния член (i започва от 1). Какво се добавя?

# наказанието: λ по диагонала, без свободния член
for i in range(1, m):
    a[i][i] += 

Пробвай

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

Грешка с наказание: MSE е 2, теглата са 3 и −4, λ е 0,1. Какво ще отпечата?

mse = 2.0
w = [3, -4]
lam = 0.1
penalty = 0.0
for x in w:
penalty += x * x
print(f"{mse + lam * penalty:.2f}")
Отговор

Идеята

Най-близкият съсед наизустява

Най-близките съседи от първия урок имат същата болест. С k = 1 моделът връща времето на най-близката минала поръчка. За поръчка от обучението най-близката е самата тя, затова грешката на обучението е 0:

# средното време на k-те най-близки поръчки
def knn(data, km, k):
used = [False] * len(data)
total = 0.0
for _ in range(k):
best = -1
for i in range(len(data)):
if used[i]:
continue
if best < 0 or abs(data[i][0] - km) < abs(data[best][0] - km):
best = i
used[best] = True
total += data[best][1]
return total / k
def knn_mse(train, data, k):
total = 0.0
for km, minutes in data:
e = knn(train, km, k) - minutes
total += e * e
return total / len(data)
print("MSE: обучение / валидация")
for k in range(1, 5):
print(f"k = {k}: {knn_mse(train, train, k):.1f} / {knn_mse(train, val, k):.1f}")
Изход
MSE: обучение / валидация
k = 1: 0.0 / 28.9
k = 2: 22.0 / 11.2
k = 3: 19.0 / 19.7
k = 4: 48.7 / 23.2

k = 1 е наизустяване в най-чист вид: 0,0 на обучението, 28,9 на валидацията. С k = 2 средното на двама съседи изглажда шума и валидацията пада на 11,2. По-голямо k пак влошава: средното от далечни поръчки размива зависимостта. Тук k е като степента при кривата: избира се с валидацията.

Идеята

Кога вреди

  • Валидация с малко данни. 8 поръчки за валидация дават шумни числа. Изборът може да е случаен: при степен 2 и 3 разликата (9,35 и 9,09) е шум. Колкото по-малко данни, толкова по-прост модел избирай.
  • Валидацията, пипана много пъти. Пробваш 100 степени, λ и признаци и избираш най-доброто на валидацията. Някое от тях е най-добро случайно. Затова тестът стои настрана, недокоснат.
  • Регуляризация без мащаб. Наказанието е еднакво за всички тегла. Ако единият признак е в километри, а другият в метри, теглото на метрите е хиляда пъти по-малко и почти не се наказва. Признаците се мащабират първо, както тук x е смалено.
  • Твърде силно наказание. λ = 10 дава недообучение: моделът не хваща и обучението. Гледай и двете грешки: ако и двете са високи, моделът е твърде прост.
  • Повече данни не са безплатни. Всяка поръчка в обучението е истинска поръчка, която трябва да се събере и провери. Понякога по-простият модел е по-евтиният лек.
По-точно, за любопитните

Формулата. Програмата търси теглата, при които сборът на квадратите на грешките плюс λ · (w₁² + w₂² + … + w₇²) е най-малък. MSE е същият сбор, делен на броя на поръчките, затова все едно λ е 8 пъти по-малко пред MSE. Това е L2 регуляризация (ridge). Ако наказанието е сборът на абсолютните стойности, |w₁| + |w₂| + …, е L1 (lasso): тя прави някои тегла точно 0, затова избира и признаци.

Защо по диагонала. Най-добрите тегла са там, където наклонът на грешката е 0 по всяко тегло. Всяко тегло дава едно уравнение. Наклонът на λ · w² е 2 · λ · w, а всички останали членове на наклона също са с двойка отпред. След деленето на 2 наказанието е λ · w в уравнението на същото тегло, тоест по диагонала на системата.

Кръстосана валидация. При малко данни: делиш обучението на 5 части, учиш се на 4 и мериш на петата, пет пъти, и вземаш средното. Всяка поръчка е веднъж валидация, а изборът не зависи от една случайна група.

Други лекове. При градиентното спускане: спиране, когато грешката на валидацията започне да расте (early stopping). При невронните мрежи: dropout, който на всяка стъпка изключва случайни неврони. Идеята е същата: моделът да не може да разчита на наизустени подробности.

В истинския код. В scikit-learn това са PolynomialFeatures, Ridge (λ се казва alpha), Lasso, KNeighborsRegressor, cross_val_score и GridSearchCV, който пробва стойностите и избира с кръстосана валидация. В ML.NET регуляризацията е параметър на треньорите (L2Regularization). Методът на Гаус там е заменен с по-стабилни методи, защото при висока степен системата е чувствителна към закръглянето.

for km, minutes in data разглобява всяка двойка. a[c], a[p] = a[p], a[c] разменя два реда.

Упражнение

Избери k

Реши задачата в редактора си.

Довърши програмата с k-NN: избери k с валидацията (най-малката грешка на валидацията), после пусни теста само за избраното k.

Изход
MSE: обучение / валидация
k = 1: 0.0 / 28.9
k = 2: 22.0 / 11.2
k = 3: 19.0 / 19.7
k = 4: 48.7 / 23.2
избрано k: 2, тест: 16.9

Валидацията избира k = 2, а тестът е 16,9. Сравни с правата: 5,57. За тази почти права зависимост правата печели и от съседите.

Още задачи

  • Степените с повече данни. Учи степените от 1 до 7 на 16 поръчки (обучение и валидация) и печатай грешката на обучението и на теста. От коя степен нататък тестът започва да расте?
  • Мрежа от λ. Пробвай λ от 0,02 до 0,5 през 0,02 за степен 7. Коя λ избира валидацията и какъв е тестът ѝ?

Тест

Провери се

1. Модел е с грешка 0 на обучението и голяма грешка на нови данни. Как се казва това?
2. Защо степента се избира с валидацията, а не с теста?
3. Какво прави регуляризацията?
4. k-NN с k = 1 е с грешка 0 на обучението. Защо това не е добра новина?
5. Кое НЕ лекува преобучението?

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