Практика 08 · Лінійна регресія¶

📖 Теорія: відкрий lecture.html у цій же теці — там інтерактивна лекція. Ця частина про те, щоб зібрати все руками.

Що зробимо:

  1. МНК через нормальне рівняння на чистому NumPy
  2. Звіримо з scikit-learn — має збігтися до 10⁻⁸
  3. Градієнтний спуск з нуля, дослідимо роль кроку η
  4. Перевіримо припущення LINE на графіку залишків
In [1]:
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$.

In [2]:
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 м²)

Метрики¶

Порахуємо все, про що йшлося в лекції.

In [3]:
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¶

Якщо все правильно — різниця буде на рівні похибки округлення.

In [4]:
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)$$

Важливо: ознаку треба стандартизувати. Інакше β₀ і β₁ живуть у різних масштабах, поверхня втрат витягується у вузький «яр», і спуск застрягає.

In [5]:
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   💥 розбіжність

Криві навчання¶

Видно, як крок визначає і швидкість, і саму можливість збіжності.

In [6]:
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()
No description has been provided for this image

4. Перевірка припущень: графік залишків¶

Головний діагностичний інструмент. У здоровій моделі залишки — безструктурна хмара навколо нуля. Будь-який візерунок = модель щось не побачила.

Порівняємо чотири сценарії з лекції.

In [7]:
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("Одна метрика ніколи не скаже всієї правди — дивись на залишки.")
No description has been provided for this image
Зверни увагу: у сценарії 'порушено L' R² високий, але залишки лягли дугою.
Одна метрика ніколи не скаже всієї правди — дивись на залишки.

💻 Завдання¶

🟢 Рівень 1 — разом¶

  1. Зміни rng.normal(0, 30, n) на більший шум. Як падає R²? Чи змінюються коефіцієнти?
  2. Побудуй графік залишків для основних даних (area, price). Чи є візерунок?

🟡 Рівень 2 — самостійно¶

  1. Додай другу ознаку (наприклад, вік будинку) і навчи модель на двох ознаках. fit_ols уже це вміє — треба лише передати матрицю.
  2. Реалізуй adjusted R² і покажи, що звичайний R² росте навіть від додавання стовпця чистого шуму, а скоригований — ні.

🔴 Рівень 3 — виклик¶

  1. Додай L2-регуляризацію: $\beta = (X^TX + \lambda I)^{-1}X^Ty$. Побудуй графік коефіцієнтів від λ. Не штрафуй вільний член!
  2. Візьми датасет із Kaggle, застосуй увесь пайплайн і перевір усі чотири припущення LINE. Що порушено і як це виправити?

🧪 Самоперевірка¶

1. Чому np.linalg.solve кращий за np.linalg.inv?

відповідь Явне обертання матриці дорожче й чисельно менш стійке. solve розкладає матрицю й розв'язує систему напряму — точніше, особливо коли матриця погано обумовлена.

2. R² моделі дорівнює −0.4. Що це означає?

відповідь Модель гірша за просте прогнозування середнім. Найчастіше — груба помилка (переплутані ознаки, оцінка на інших даних, забута стандартизація).

3. Навіщо стандартизувати ознаки перед градієнтним спуском, якщо для МНК це не треба?

відповідь МНК розв'язує рівняння точно, масштаб не заважає. Градієнтний спуск іде по поверхні втрат: за різних масштабів ознак вона витягується у вузький «яр», і єдиний спільний крок η або завеликий для однієї осі, або замалий для іншої.