Практика 08 · Лінійна регресія¶
📖 Теорія: відкрий
lecture.htmlу цій же теці — там інтерактивна лекція. Ця частина про те, щоб зібрати все руками.
Що зробимо:
- МНК через нормальне рівняння на чистому NumPy
- Звіримо з
scikit-learn— має збігтися до 10⁻⁸ - Градієнтний спуск з нуля, дослідимо роль кроку η
- Перевіримо припущення LINE на графіку залишків
import numpy as np
import matplotlib.pyplot as plt
rng = np.random.default_rng(42)
# синтетичні дані: площа будинку -> ціна
n = 60
area = rng.uniform(80, 260, n)
price = 1.9 * area + 40 + rng.normal(0, 30, n)
print(f"{n} обʼєктів, площа {area.min():.0f}–{area.max():.0f} м², "
f"ціна {price.min():.0f}–{price.max():.0f} тис.")
60 обʼєктів, площа 81–256 м², ціна 143–543 тис.
1. МНК руками¶
Нормальне рівняння: $\beta = (X^T X)^{-1} X^T y$
Стовпець одиниць у матриці $X$ — це і є вільний член $\beta_0$.
def fit_ols(x, y):
"""Нормальне рівняння. Повертає (beta_0, beta_1)."""
X = np.c_[np.ones_like(x), x] # [1, x]
# solve надійніший за inv: не обертає матрицю явно
return np.linalg.solve(X.T @ X, X.T @ y)
b0, b1 = fit_ols(area, price)
print(f"beta_0 = {b0:8.3f} (вільний член)")
print(f"beta_1 = {b1:8.3f} (ціна за 1 м²)")
beta_0 = 35.295 (вільний член) beta_1 = 1.891 (ціна за 1 м²)
Метрики¶
Порахуємо все, про що йшлося в лекції.
def metrics(y, y_hat):
resid = y - y_hat
mse = np.mean(resid ** 2)
return {
"MSE": mse,
"RMSE": np.sqrt(mse),
"MAE": np.mean(np.abs(resid)),
"R2": 1 - np.sum(resid ** 2) / np.sum((y - y.mean()) ** 2),
}
pred = b0 + b1 * area
for k, v in metrics(price, pred).items():
print(f"{k:>5} = {v:10.3f}")
# baseline: завжди прогнозуємо середнє
print(f"\nR2 моделі-середнього = {metrics(price, np.full_like(price, price.mean()))['R2']:.3f}")
MSE = 503.293 RMSE = 22.434 MAE = 18.641 R2 = 0.948 R2 моделі-середнього = 0.000
2. Звірка зі scikit-learn¶
Якщо все правильно — різниця буде на рівні похибки округлення.
from sklearn.linear_model import LinearRegression
sk = LinearRegression().fit(area.reshape(-1, 1), price)
print(f"наш : b0={b0:.6f} b1={b1:.6f}")
print(f"sklearn: b0={sk.intercept_:.6f} b1={sk.coef_[0]:.6f}")
print(f"різниця: {abs(b0 - sk.intercept_):.2e}, {abs(b1 - sk.coef_[0]):.2e}")
assert np.allclose([b0, b1], [sk.intercept_, sk.coef_[0]]), "розрахунок розійшовся!"
print("\n✅ збігається")
наш : b0=35.294513 b1=1.891316 sklearn: b0=35.294513 b1=1.891316 різниця: 1.05e-12, 5.55e-15 ✅ збігається
3. Градієнтний спуск з нуля¶
Градієнт MSE по коефіцієнтах:
$$\nabla L = \frac{2}{n} X^T (X\beta - y)$$
Важливо: ознаку треба стандартизувати. Інакше β₀ і β₁ живуть у різних масштабах, поверхня втрат витягується у вузький «яр», і спуск застрягає.
def gradient_descent(x, y, eta=0.1, steps=100):
"""Повертає (шлях коефіцієнтів, історія втрат) у стандартизованих координатах."""
xs = (x - x.mean()) / x.std()
X = np.c_[np.ones_like(xs), xs]
beta = np.zeros(2)
path, hist = [beta.copy()], []
for _ in range(steps):
grad = 2 / len(y) * X.T @ (X @ beta - y)
beta = beta - eta * grad
path.append(beta.copy())
hist.append(np.mean((y - X @ beta) ** 2))
return np.array(path), np.array(hist)
# опорне значення: точний мінімум у тих самих координатах
areas_s = (area - area.mean()) / area.std()
mse_opt = metrics(price, np.c_[np.ones(n), areas_s] @ fit_ols(areas_s, price))["MSE"]
print(f"мінімум MSE = {mse_opt:.2f}\n")
for eta in [0.001, 0.01, 0.1, 0.5, 1.01]:
_, hist = gradient_descent(area, price, eta=eta, steps=60)
final = hist[-1]
if not np.isfinite(final) or final > hist[0]:
verdict = "💥 розбіжність"
elif final > mse_opt * 1.1:
verdict = "🐌 не встигло"
else:
verdict = "✅ збіглося"
shown = f"{final:12.2f}" if np.isfinite(final) else " inf"
print(f"eta={eta:<6} MSE={shown} {verdict}")
мінімум MSE = 503.29 eta=0.001 MSE= 111662.60 🐌 не встигло eta=0.01 MSE= 13017.69 🐌 не встигло eta=0.1 MSE= 503.29 ✅ збіглося eta=0.5 MSE= 503.29 ✅ збіглося eta=1.01 MSE= 1522106.78 💥 розбіжність
Криві навчання¶
Видно, як крок визначає і швидкість, і саму можливість збіжності.
fig, ax = plt.subplots(figsize=(9, 4.5))
for eta in [0.001, 0.01, 0.1, 0.5]:
_, hist = gradient_descent(area, price, eta=eta, steps=60)
ax.plot(hist, label=f"η = {eta}", lw=2)
ax.axhline(mse_opt, color="teal", ls="--", lw=1.5, label="мінімум (МНК)")
ax.set_yscale("log")
ax.set_xlabel("ітерація"); ax.set_ylabel("MSE (лог. шкала)")
ax.set_title("Швидкість збіжності залежно від кроку")
ax.legend(); ax.grid(alpha=.25)
plt.tight_layout(); plt.show()
4. Перевірка припущень: графік залишків¶
Головний діагностичний інструмент. У здоровій моделі залишки — безструктурна хмара навколо нуля. Будь-який візерунок = модель щось не побачила.
Порівняємо чотири сценарії з лекції.
def make_case(kind, seed=7):
r = np.random.default_rng(seed)
x = np.linspace(10, 100, 45)
if kind == "ok":
y = 12 + 1.15 * x + r.normal(0, 7, x.size)
elif kind == "curve":
y = 8 + 0.042 * x ** 2 + r.normal(0, 7, x.size)
elif kind == "funnel":
y = 12 + 1.15 * x + r.normal(0, 1, x.size) * (1.6 + x * 0.42)
elif kind == "outlier":
y = 12 + 1.15 * x + r.normal(0, 6, x.size)
y[[3, 6]] = [205, 192]
return x, y
cases = [("ok", "усе гаразд"), ("curve", "порушено L"),
("funnel", "порушено E"), ("outlier", "викиди")]
fig, axes = plt.subplots(2, 4, figsize=(15, 6.5))
for col, (kind, title) in enumerate(cases):
x, y = make_case(kind)
c0, c1 = fit_ols(x, y)
yh = c0 + c1 * x
res = y - yh
r2 = metrics(y, yh)["R2"]
top = axes[0, col]
top.scatter(x, y, s=22, color="crimson", zorder=3)
top.plot(x, yh, color="teal", lw=2)
top.set_title(f"{title}\nR²={r2:.3f} β₁={c1:.2f}", fontsize=11)
top.grid(alpha=.2)
bot = axes[1, col]
bot.axhline(0, color="gray", ls="--", lw=1.4)
bot.vlines(x, 0, res, color="darkorange", lw=1.2)
bot.scatter(x, res, s=18, color="darkorange", zorder=3)
bot.set_xlabel("x"); bot.grid(alpha=.2)
if col == 0:
top.set_ylabel("дані та підгонка"); bot.set_ylabel("залишки")
plt.tight_layout(); plt.show()
print("Зверни увагу: у сценарії 'порушено L' R² високий, але залишки лягли дугою.")
print("Одна метрика ніколи не скаже всієї правди — дивись на залишки.")
Зверни увагу: у сценарії 'порушено L' R² високий, але залишки лягли дугою. Одна метрика ніколи не скаже всієї правди — дивись на залишки.
💻 Завдання¶
🟢 Рівень 1 — разом¶
- Зміни
rng.normal(0, 30, n)на більший шум. Як падає R²? Чи змінюються коефіцієнти? - Побудуй графік залишків для основних даних (
area,price). Чи є візерунок?
🟡 Рівень 2 — самостійно¶
- Додай другу ознаку (наприклад, вік будинку) і навчи модель на двох ознаках.
fit_olsуже це вміє — треба лише передати матрицю. - Реалізуй
adjusted R²і покажи, що звичайний R² росте навіть від додавання стовпця чистого шуму, а скоригований — ні.
🔴 Рівень 3 — виклик¶
- Додай L2-регуляризацію: $\beta = (X^TX + \lambda I)^{-1}X^Ty$. Побудуй графік коефіцієнтів від λ. Не штрафуй вільний член!
- Візьми датасет із Kaggle, застосуй увесь пайплайн і перевір усі чотири припущення LINE. Що порушено і як це виправити?
🧪 Самоперевірка¶
1. Чому np.linalg.solve кращий за np.linalg.inv?
відповідь
Явне обертання матриці дорожче й чисельно менш стійке.solve розкладає
матрицю й розв'язує систему напряму — точніше, особливо коли матриця погано обумовлена.
2. R² моделі дорівнює −0.4. Що це означає?
відповідь
Модель гірша за просте прогнозування середнім. Найчастіше — груба помилка (переплутані ознаки, оцінка на інших даних, забута стандартизація).3. Навіщо стандартизувати ознаки перед градієнтним спуском, якщо для МНК це не треба?