← todos os textos

Aula 3 de 13
  1. 1O que é aprender com dados
  2. 2Regressão linear e função de custo
  3. 3Gradiente descendente
  4. 4Classificação com regressão logística
  5. 5k-NN (vizinhos mais próximos)
  6. 6k-means (agrupamento)
  7. 7Overfitting e a divisão em treino, validação e teste

Gradiente descendente

Objetivo: treinar a regressão linear “descendo a ladeira” do custo, do zero em NumPy, e entender por que padronizar os dados muda tudo.

Por que isso importa: é assim que praticamente todo modelo moderno é treinado, de uma reta até um modelo de linguagem com bilhões de parâmetros. A receita é sempre a mesma: calcular o gradiente do custo e dar um passinho na direção contrária, milhões de vezes.

import numpy as np
import matplotlib.pyplot as plt
from sklearn.linear_model import LinearRegression

AZUL, LARANJA, VERDE = "#2a78d6", "#eb6834", "#1baf7a"


def gerar_casas(n=100, seed=42):
    rng = np.random.default_rng(seed)
    area = rng.uniform(40, 200, size=n)             # m²
    quartos = rng.integers(1, 5, size=n)            # 1 a 4
    idade = rng.uniform(0, 40, size=n)              # anos
    preco = 2.0 * area + 25 * quartos - 1.5 * idade + 50 + rng.normal(0, 20, size=n)   # mil R$
    return area, quartos, idade, preco

1. O problema: nem todo modelo tem fórmula

Na aula 01.02, a melhor reta saiu de uma fórmula pronta (lstsq e LinearRegression). Só que essa fórmula só existe para modelos lineares com custo MSE. Uma rede neural não tem fórmula nenhuma, e o único jeito é procurar.

A procura é a bolinha da aula 00.05: começar num chute e dar passos morro abaixo no terreno do custo.

w←w−taxa×∂ MSE∂ww \leftarrow w - \text{taxa} \times \frac{\partial\, \text{MSE}}{\partial w}

Pra isso, falta uma peça: a derivada do MSE em relação a cada parâmetro. Primeiro, a resposta da fórmula, pra comparar no fim:

area, quartos, idade, preco = gerar_casas()

# o scikit-learn quer uma TABELA (n linhas, 1 coluna por feature), mesmo com uma feature só: daí o reshape(-1, 1)
lr1 = LinearRegression().fit(area.reshape(-1, 1), preco)
mse_minimo_1 = np.mean((lr1.predict(area.reshape(-1, 1)) - preco) ** 2)

print(f"pela fórmula: preço = {lr1.coef_[0]:.3f} × área + {lr1.intercept_:.2f}")
print(f"MSE mínimo possível: {mse_minimo_1:.0f}")
pela fórmula: preço = 1.999 × área + 81.12
MSE mínimo possível: 1397

2. O gradiente do MSE

O modelo é previsto=w×aˊrea+b\text{previsto} = w \times \text{área} + b. Para uma casa, o erro é erro=previsto−real\text{erro} = \text{previsto} - \text{real}, e o custo dela é erro2\text{erro}^2.

O custo depende do erro, que depende de ww. É a regra da cadeia da aula 00.05: derivada de fora vezes derivada de dentro.

∂ erro2∂w=2 erro⏟de fora×aˊrea⏟de dentro∂ erro2∂b=2 erro×1\frac{\partial\, \text{erro}^2}{\partial w} = \underbrace{2\,\text{erro}}_{\text{de fora}} \times \underbrace{\text{área}}_{\text{de dentro}} \qquad\qquad \frac{\partial\, \text{erro}^2}{\partial b} = 2\,\text{erro} \times 1

O MSE é a média dos custos das casas, então o gradiente também é a média:

∂ MSE∂w=2n∑ierroi×aˊreai∂ MSE∂b=2n∑ierroi\frac{\partial\,\text{MSE}}{\partial w} = \frac{2}{n}\sum_i \text{erro}_i \times \text{área}_i \qquad\qquad \frac{\partial\,\text{MSE}}{\partial b} = \frac{2}{n}\sum_i \text{erro}_i

Com 3 casas e o chute w=2w = 2, b=0b = 0:

área real previsto (2 × área) erro erro × área
50 150 100 −50 −2500
100 250 200 −50 −5000
150 300 300 0 0
  • ∂MSE/∂w=23×(−7500)=−5000\partial\text{MSE}/\partial w = \frac{2}{3} \times (-7500) = -5000
  • ∂MSE/∂b=23×(−100)≈−66,7\partial\text{MSE}/\partial b = \frac{2}{3} \times (-100) \approx -66{,}7

Os dois são negativos, ou seja, aumentar ww e bb diminui o custo. Faz sentido: o modelo está prevendo abaixo do real.

area3 = np.array([50, 100, 150])
preco3 = np.array([150, 250, 300])
w, b = 2.0, 0.0

erro = (w * area3 + b) - preco3
print("erro:", erro)
print("dMSE/dw:", 2 / len(area3) * np.sum(erro * area3))
print("dMSE/db:", 2 / len(area3) * np.sum(erro))


# conferindo com a derivada numérica da aula 00.05 (diferença central)
def mse3(w, b):
    return np.mean((w * area3 + b - preco3) ** 2)

h = 1e-5
print("numérica w:", (mse3(w + h, b) - mse3(w - h, b)) / (2 * h))
print("numérica b:", (mse3(w, b + h) - mse3(w, b - h)) / (2 * h))
erro: [-50. -50.   0.]
dMSE/dw: -5000.0
dMSE/db: -66.66666666666666
numérica w: -5000.000000029559
numérica b: -66.66666668024845

3. O laço de treino

Juntando as peças: calcular o erro, calcular o gradiente, dar o passo, e repetir. Cada repetição que passa por todos os dados se chama época. Em TypeScript:

let w = 0, b = 0;
for (let epoca = 0; epoca < epocas; epoca++) {
  let dw = 0, db = 0;
  for (let i = 0; i < n; i++) {
    const erro = (w * area[i] + b) - preco[i];
    dw += (2 / n) * erro * area[i];
    db += (2 / n) * erro;
  }
  w -= taxa * dw;
  b -= taxa * db;
}

Em NumPy, o laço de dentro some (vetorização):

def treinar_1d(x, y, taxa, epocas, guardar=(1, 2, 5)):
    w, b = 0.0, 0.0                  # chute inicial
    historico = []                   # MSE de cada época, pra desenhar a curva
    retas = {}                       # (w, b) de algumas épocas, pra ver a reta mudando
    for epoca in range(epocas):
        if epoca in guardar:
            retas[epoca] = (w, b)
        erro = (w * x + b) - y
        historico.append(np.mean(erro ** 2))
        dw = 2 * np.mean(erro * x)   # = (2/n) × soma(erro × x)
        db = 2 * np.mean(erro)
        w -= taxa * dw
        b -= taxa * db
    retas[epocas] = (w, b)
    return w, b, historico, retas


w, b, historico, retas = treinar_1d(area, preco, taxa=1e-5, epocas=200)
print(f"depois de 200 épocas: preço = {w:.3f} × área + {b:.2f}   (MSE {historico[-1]:.0f})")
depois de 200 épocas: preço = 2.604 × área + 0.06   (MSE 2186)

A taxa é um número minúsculo (0,00001), e o motivo aparece na seção 4. Primeiro, o resultado:

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4.2))

ax1.plot(historico, color=AZUL)
ax1.axhline(mse_minimo_1, color=LARANJA, linestyle="--", label="mínimo (fórmula)")
ax1.set_yscale("log")                     # o custo cai de 100 mil pra 2 mil: a escala log mostra as duas pontas
ax1.set_title("Custo a cada época")
ax1.set_xlabel("época")
ax1.set_ylabel("MSE (escala log)")
ax1.legend()
ax1.grid(alpha=0.3)

x_reta = np.array([40, 200])
cores = plt.cm.Blues(np.linspace(0.35, 1, len(retas)))    # épocas mais novas = azul mais escuro
ax2.scatter(area, preco, color="gray", alpha=0.5, label="casas")
for (epoca, (w_e, b_e)), cor in zip(retas.items(), cores):
    ax2.plot(x_reta, w_e * x_reta + b_e, color=cor, label=f"época {epoca}")
ax2.plot(x_reta, lr1.predict(x_reta.reshape(-1, 1)), color=LARANJA, linestyle="--", label="fórmula (01.02)")
ax2.set_title("A reta ao longo do treino")
ax2.set_xlabel("área (m²)")
ax2.set_ylabel("preço (mil R$)")
ax2.legend(fontsize=9)
ax2.grid(alpha=0.3)

fig.tight_layout()
plt.show()

Gráfico gerado pelo código acima

O custo despencou nas primeiras épocas e depois empacou num valor acima do mínimo. A reta de 200 épocas ficou mais inclinada que a da fórmula e passa perto da origem, porque o b quase não saiu do zero. O que aconteceu?

4. O problema da escala

Compare os dois gradientes: o de w tem erro × área, com área na casa de 120, e o de b tem só erro. Por causa disso, o terreno do custo é dezenas de milhares de vezes mais íngreme na direção do w do que na do b. É o “vale estreito” do desafio da aula 00.05.

  • Com uma taxa um pouco maior, o w passa do ponto e o treino explode.
  • Com a taxa pequena que o w aguenta, o b anda a passos de formiga.
w, b, historico_lento, _ = treinar_1d(area, preco, taxa=5e-5, epocas=100_000)    # demora alguns segundos
print(f"100 mil épocas: preço = {w:.3f} × área + {b:.2f}   (MSE {historico_lento[-1]:.0f}; o mínimo é {mse_minimo_1:.0f})")

with np.errstate(over="ignore", invalid="ignore"):     # esconde o aviso de "número grande demais"
    _, _, historico_explode, _ = treinar_1d(area, preco, taxa=7e-5, epocas=30)
print(f"taxa 7e-5: MSE depois de 30 épocas = {historico_explode[-1]:.1e}   (explodiu)")
100 mil épocas: preço = 2.181 × área + 56.73   (MSE 1468; o mínimo é 1397)
taxa 7e-5: MSE depois de 30 épocas = 7.1e+09   (explodiu)

A solução é padronizar, com o z-score das aulas 00.02 e 00.06: z=(aˊrea−meˊdia)/desvioz = (\text{área} - \text{média}) / \text{desvio}. Depois disso, a feature tem média 0 e desvio 1, na mesma escala que o b, e o terreno vira uma tigela redonda:

media_area, desvio_area = area.mean(), area.std()
z = (area - media_area) / desvio_area


def caminho(x, y, taxa, epocas):
    # os (w, b) visitados pelo gradiente descendente, época a época
    w, b, pontos = 0.0, 0.0, [(0.0, 0.0)]
    for _ in range(epocas):
        erro = (w * x + b) - y
        w, b = w - taxa * 2 * np.mean(erro * x), b - taxa * 2 * np.mean(erro)   # atualiza os dois ao mesmo tempo
        pontos.append((w, b))
    return np.array(pontos)


def terreno(x, y, ws, bs):
    # MSE para cada combinação (w, b) de uma grade (o meshgrid da aula 00.03)
    W, B = np.meshgrid(ws, bs)
    Z = np.zeros_like(W)
    for i in range(len(x)):                       # soma o erro² de cada casa na grade inteira
        Z += (W * x[i] + B - y[i]) ** 2
    return W, B, Z / len(x)


lr_z = LinearRegression().fit(z.reshape(-1, 1), preco)    # o mínimo, na escala padronizada
paineis = [
    ("sem padronizar: vale estreito", area, 5e-5, 2000, np.linspace(0, 4.5, 200), np.linspace(-20, 120, 200), (lr1.coef_[0], lr1.intercept_)),
    ("padronizado: tigela redonda", z, 0.3, 30, np.linspace(-100, 320, 200), np.linspace(-20, 400, 200), (lr_z.coef_[0], lr_z.intercept_)),
]

fig, axs = plt.subplots(1, 2, figsize=(12, 4.8))
for ax, (titulo, x, taxa, epocas, ws, bs, minimo) in zip(axs, paineis):
    W, B, Z = terreno(x, preco, ws, bs)
    ax.contourf(W, B, np.log10(Z), levels=20, cmap="Blues")      # log10: senão só o fundo do vale aparece
    pts = caminho(x, preco, taxa, epocas)
    ax.plot(pts[:, 0], pts[:, 1], color=LARANJA, marker=".", markersize=4, label=f"{epocas} épocas (taxa {taxa})")
    ax.plot(*minimo, marker="*", markersize=16, color="black", linestyle="none", label="mínimo")
    ax.set_title(titulo)
    ax.set_xlabel("w")
    ax.set_ylabel("b")
    ax.legend(loc="upper right", fontsize=9)
axs[1].set_aspect("equal")     # mesma escala nos dois eixos, pra ver que a tigela é redonda
fig.tight_layout()
plt.show()

Gráfico gerado pelo código acima

À esquerda, a bolinha cai no fundo do vale logo nos primeiros passos. Nas 2.000 épocas seguintes, quase não anda, porque o fundo do vale é quase plano na direção do b, e a estrela continua longe. À direita, em 30 épocas, ela vai direto.

Comparando as curvas de custo:

w_pad, b_pad, historico_pad, _ = treinar_1d(z, preco, taxa=0.3, epocas=50)
print(f"padronizado, 50 épocas: preço = {w_pad:.3f} × z + {b_pad:.2f}   (MSE {historico_pad[-1]:.0f})")

fig, ax = plt.subplots(figsize=(7, 4))
ax.plot(historico_lento[:50], color=AZUL, label="área original (taxa 5e-5)")
ax.plot(historico_pad, color=VERDE, label="área padronizada (taxa 0.3)")
ax.axhline(mse_minimo_1, color=LARANJA, linestyle="--", label="mínimo")
ax.set_yscale("log")
ax.set_title("As primeiras 50 épocas")
ax.set_xlabel("época")
ax.set_ylabel("MSE (escala log)")
ax.legend()
ax.grid(alpha=0.3)
plt.show()
padronizado, 50 épocas: preço = 87.081 × z + 316.77   (MSE 1397)

Gráfico gerado pelo código acima

Um detalhe: o modelo padronizado recebe z, e não a área. Pra usar com a área de verdade, é preciso desfazer a padronização e converter o w e o b de volta. Essa conversão é o exercício 2.

Padronizar as features antes de treinar é regra em ML, e não só pra regressão linear: redes neurais sofrem do mesmo jeito.

5. Várias features

Agora o preço depende de área, quartos e idade. O modelo é o da aula 00.04: previsto=Xw+b\text{previsto} = X w + b, com um peso por coluna de XX.

O gradiente de cada peso jj é a mesma conta de antes, usando a coluna jj:

∂ MSE∂wj=2n∑ierroi×Xij\frac{\partial\,\text{MSE}}{\partial w_j} = \frac{2}{n}\sum_i \text{erro}_i \times X_{ij}

Em TypeScript, seriam dois laços:

const dw = new Array(d).fill(0);
for (let i = 0; i < n; i++)
  for (let j = 0; j < d; j++)
    dw[j] += (2 / n) * erro[i] * X[i][j];

Para cada coluna jj, a soma é um produto escalar entre a coluna e o vetor de erros. Todas as colunas de uma vez: X.T @ erro. Pela regra dos formatos da aula 00.04, X.T é (3, n) e erro é (n,), então o resultado é (3,): um gradiente por peso.

X = np.column_stack([area, quartos, idade])     # junta os vetores como colunas: (100, 3)
media, desvio = X.mean(axis=0), X.std(axis=0)    # uma média e um desvio por coluna
Xz = (X - media) / desvio                        # padroniza cada coluna (broadcasting)

w = np.zeros(3)
b = 0.0
for epoca in range(300):
    erro = (Xz @ w + b) - preco
    w -= 0.1 * (2 / len(preco)) * (Xz.T @ erro)
    b -= 0.1 * 2 * np.mean(erro)

lr = LinearRegression().fit(Xz, preco)
mse_minimo = np.mean((lr.predict(Xz) - preco) ** 2)

print("gradiente descendente:", w.round(3), round(b, 3))
print("LinearRegression:     ", lr.coef_.round(3), round(lr.intercept_, 3))
gradiente descendente: [ 84.674  27.531 -19.663] 316.766
LinearRegression:      [ 84.674  27.531 -19.663] 316.766

Os mesmos pesos, até a terceira casa decimal. Voltando para a escala original, dá pra comparar com os números que geraram os dados em gerar_casas:

w_original = w / desvio
b_original = b - np.sum(w * media / desvio)

print("aprendido: ", w_original.round(2), round(b_original, 1))
print("verdadeiro:", np.array([2.0, 25.0, -1.5]), 50.0)
aprendido:  [ 1.94 26.45 -1.64] 57.3
verdadeiro: [ 2.  25.  -1.5] 50.0

Perto, mas não iguais. A diferença vem do ruído (rng.normal) que colocamos nos preços: com 100 casas, ele ainda atrapalha um pouco. Com mais casas, o modelo chegaria mais perto dos números verdadeiros.

6. Mini-batch: um pedaço dos dados por passo

O problema: aqui cada passo olha as 100 casas. Um modelo de linguagem treina com bilhões de exemplos, e olhar todos a cada passo seria caro demais.

A ideia: estimar o gradiente com um pedaço aleatório dos dados, um mini-batch de, por exemplo, 10 casas. A estimativa tem ruído, mas custa 10 vezes menos. Isso se chama gradiente descendente estocástico (estocástico = aleatório).

def custo(w, b):
    return np.mean((Xz @ w + b - preco) ** 2)


# com todas as casas em cada passo
w, b = np.zeros(3), 0.0
custo_completo = [custo(w, b)]
for passo in range(10):
    erro = (Xz @ w + b) - preco
    w -= 0.1 * (2 / len(preco)) * (Xz.T @ erro)
    b -= 0.1 * 2 * np.mean(erro)
    custo_completo.append(custo(w, b))

# com mini-batches de 10 casas
rng = np.random.default_rng(seed=0)
w, b = np.zeros(3), 0.0
custo_mb = [custo(w, b)]
for epoca in range(10):
    ordem = rng.permutation(len(preco))              # embaralha a ordem das casas a cada época
    for inicio in range(0, len(preco), 10):
        lote = ordem[inicio:inicio + 10]              # os índices das 10 casas deste passo
        erro = (Xz[lote] @ w + b) - preco[lote]       # Xz[lote] pega só as linhas desses índices
        w -= 0.1 * (2 / len(lote)) * (Xz[lote].T @ erro)
        b -= 0.1 * 2 * np.mean(erro)
        custo_mb.append(custo(w, b))

fig, ax = plt.subplots(figsize=(7, 4))
ax.plot(np.arange(len(custo_completo)) * 100, custo_completo, color=AZUL, marker="o", label="todas as casas (100 por passo)")
ax.plot(np.arange(len(custo_mb)) * 10, custo_mb, color=VERDE, label="mini-batch (10 por passo)")
ax.axhline(mse_minimo, color=LARANJA, linestyle="--", label="mínimo")
ax.set_yscale("log")
ax.set_title("Mesmo esforço: 1.000 casas lidas")
ax.set_xlabel("casas usadas nos cálculos até agora")
ax.set_ylabel("MSE (escala log)")
ax.legend()
ax.grid(alpha=0.3)
plt.show()

Gráfico gerado pelo código acima

Lendo a mesma quantidade de casas, o mini-batch deu 100 passos (contra 10) e chegou bem mais perto do mínimo. A curva treme, porque cada lote puxa pra um lado, mas a tendência é descer.

É exatamente assim que as redes neurais são treinadas. No PyTorch, o DataLoader(dados, batch_size=32, shuffle=True) faz o embaralhamento e o corte em lotes (disciplina 02). No scikit-learn, o SGDRegressor é esta regressão linear treinada com mini-batch.


Exercícios

Os exercícios usam Xz, preco, lr e outras variáveis das seções anteriores, então elas precisam ter rodado antes. A célula abaixo cria as funções de conferência.

Código de apoio dos exercícios
def confere(obtido, esperado, tolerancia=1e-6):
    if obtido is None:
        print("⏳ ainda não implementado")
        return
    obtido, esperado = np.asarray(obtido, dtype=float), np.asarray(esperado, dtype=float)
    if obtido.shape != esperado.shape:
        print(f"❌ formato esperado {esperado.shape}, mas veio {obtido.shape}")
    elif np.allclose(obtido, esperado, rtol=tolerancia, atol=tolerancia):
        print("✅ certo!")
    else:
        print(f"❌ esperado:\n{esperado}\nmas veio:\n{obtido}")


def confere_taxa(taxa, epocas=20):
    if taxa is None:
        print("⏳ ainda não escolheu a taxa")
        return
    w, b = np.zeros(Xz.shape[1]), 0.0
    custos = []
    with np.errstate(over="ignore", invalid="ignore"):
        for _ in range(epocas + 1):
            erro = (Xz @ w + b) - preco
            custos.append(np.mean(erro ** 2))
            w = w - taxa * (2 / len(preco)) * (Xz.T @ erro)
            b = b - taxa * 2 * np.mean(erro)
    fig, ax = plt.subplots(figsize=(6, 3.5))
    ax.plot(custos, color=AZUL, marker="o", markersize=3, label=f"taxa = {taxa}")
    ax.axhline(mse_minimo, color=LARANJA, linestyle="--", label="mínimo")
    ax.set_yscale("log")
    ax.set_title(f"{epocas} épocas com a sua taxa")
    ax.set_xlabel("época")
    ax.set_ylabel("MSE (escala log)")
    ax.legend()
    ax.grid(alpha=0.3)
    plt.show()
    razao = custos[-1] / mse_minimo
    if not np.isfinite(razao) or custos[-1] > custos[0]:
        print("❌ divergiu: a taxa é grande demais")
    elif razao > 1.01:
        print(f"❌ depois de {epocas} épocas, ainda está {100 * (razao - 1):.1f}% acima do mínimo: a taxa é pequena demais")
    else:
        print(f"✅ certo! chegou a {100 * (razao - 1):.2f}% do mínimo")

Exercício 1 · Os gradientes

Escreva gradientes(area, preco, w, b), que devolve (dw, db): as derivadas do MSE em relação a w e a b, para o modelo previsto = w × área + b. Sem laço.

def gradientes(area, preco, w, b):
    ...  # seu código aqui


confere(gradientes(np.array([50, 100, 150]), np.array([150, 250, 300]), 2.0, 0.0), [-5000, -200 / 3])   # a tabela da seção 2
confere(gradientes(np.array([1, 2, 3]), np.array([2, 4, 6]), 2.0, 0.0), [0, 0])                         # reta perfeita: nada a corrigir
Dica

Siga a tabela da seção 2: primeiro erro = previsto - real, depois as duas médias.

Gabarito
def gradientes(area, preco, w, b):
    erro = (w * area + b) - preco
    dw = 2 * np.mean(erro * area)
    db = 2 * np.mean(erro)
    return dw, db

Exercício 2 · Voltando para a escala original

O modelo padronizado da seção 4 é prec¸o=wz×z+bz\text{preço} = w_z \times z + b_z, com z=(aˊrea−meˊdia)/desvioz = (\text{área} - \text{média}) / \text{desvio}.

Escreva voltar_escala(w_z, b_z, media, desvio), que devolve (w, b) tais que prec¸o=w×aˊrea+b\text{preço} = w \times \text{área} + b dê exatamente a mesma previsão.

def voltar_escala(w_z, b_z, media, desvio):
    ...  # seu código aqui


confere(voltar_escala(10, 300, 100, 50), [0.2, 280])
confere(voltar_escala(w_pad, b_pad, media_area, desvio_area), [lr1.coef_[0], lr1.intercept_], tolerancia=1e-4)   # tem que bater com a fórmula
Dica

Substitua o zz no modelo e abra a conta:

wz×aˊrea−meˊdiadesvio+bz=wzdesvio⏟multiplica a aˊrea×aˊrea+(bz−…)⏟o que sobraw_z \times \frac{\text{área} - \text{média}}{\text{desvio}} + b_z = \underbrace{\frac{w_z}{\text{desvio}}}_{\text{multiplica a área}} \times \text{área} + \underbrace{\left(b_z - \ldots\right)}_{\text{o que sobra}}

Gabarito
def voltar_escala(w_z, b_z, media, desvio):
    w = w_z / desvio
    b = b_z - w_z * media / desvio
    return w, b

Exercício 3 · O treino completo

Escreva treinar(X, y, taxa, epocas) para qualquer número de features:

  • w começa com um zero por coluna de X, e b começa em 0;
  • a cada época, guarda o MSE antes do passo numa lista historico (então historico[0] é o custo do chute inicial);
  • devolve (w, b, historico).
def treinar(X, y, taxa, epocas):
    ...  # seu código aqui


resultado = treinar(Xz, preco, taxa=0.1, epocas=300)
if resultado is None:
    confere(None, None)
else:
    w, b, historico = resultado
    confere(w, lr.coef_, tolerancia=1e-4)                      # os mesmos pesos do LinearRegression
    confere(b, lr.intercept_, tolerancia=1e-4)
    confere(len(historico), 300)
    confere(historico[0], np.mean(preco ** 2))                 # com w = 0 e b = 0, o erro é -preço
Dica

É o laço da seção 5 dentro de uma função. Um peso por coluna: np.zeros(X.shape[1]).

Gabarito
def treinar(X, y, taxa, epocas):
    w = np.zeros(X.shape[1])
    b = 0.0
    historico = []
    for _ in range(epocas):
        erro = (X @ w + b) - y
        historico.append(np.mean(erro ** 2))
        w = w - taxa * (2 / len(y)) * (X.T @ erro)
        b = b - taxa * 2 * np.mean(erro)
    return w, b, historico

O _ no for _ in range(epocas) é o nome que se usa em Python para uma variável que não vai ser usada: aqui, só importa repetir.

Exercício 4 · Escolhendo a taxa de aprendizado

Com os dados padronizados (Xz), escolha uma taxa que chegue a no máximo 1% acima do mínimo em só 20 épocas, sem divergir. A conferência desenha a curva de custo com a sua taxa: experimente, olhe a curva e ajuste.

Escolher a taxa é uma das tarefas mais comuns (e mais chatas) de quem treina modelos.

taxa_escolhida = None   # troque por um número, por exemplo 0.01, e vá ajustando

confere_taxa(taxa_escolhida)
Dica

Comece com 0.01 e vá multiplicando por 3. Se divergir, volte um pouco.

Gabarito

Qualquer taxa entre uns 0,12 e uns 0,77 passa, por exemplo taxa_escolhida = 0.3.

  • Abaixo disso, a bolinha ainda está descendo quando as 20 épocas acabam.
  • Acima de uns 0,83, cada passo pula o fundo da tigela e cai mais alto do outro lado, como a taxa 1.05 da aula 00.05, e o custo explode.
  • Entre 0,77 e 0,83, ela não explode, mas fica tanto tempo pulando de um lado pro outro que 20 épocas não bastam.

Exercício 5 (desafio) · Treino com mini-batch

Escreva treinar_minibatch(X, y, taxa, epocas, tamanho_lote, seed):

  • a cada época, embaralha a ordem dos exemplos com np.random.default_rng(seed) e percorre os lotes de tamanho_lote exemplos, dando um passo por lote;
  • no fim de cada época, guarda em historico o MSE calculado com todos os exemplos;
  • devolve (w, b, historico).

Como cada passo usa só um pedaço dos dados, o resultado não cai exatamente no mínimo: ele fica “tremendo” perto do fundo. Por isso, a conferência aceita uma pequena diferença.

def treinar_minibatch(X, y, taxa, epocas, tamanho_lote, seed):
    ...  # seu código aqui


resultado = treinar_minibatch(Xz, preco, taxa=0.05, epocas=50, tamanho_lote=10, seed=0)
if resultado is None:
    confere(None, None)
else:
    w, b, historico = resultado
    confere(len(historico), 50)
    confere(historico[-1], mse_minimo, tolerancia=0.02)        # até 2% acima do mínimo
    confere(w, lr.coef_, tolerancia=0.05)                       # pesos perto dos do LinearRegression
    confere(b, lr.intercept_, tolerancia=0.05)
Dica

Parta do código de mini-batch da seção 6. O MSE com todos os exemplos vai fora do laço dos lotes, no fim de cada época.

Gabarito
def treinar_minibatch(X, y, taxa, epocas, tamanho_lote, seed):
    rng = np.random.default_rng(seed)
    w = np.zeros(X.shape[1])
    b = 0.0
    historico = []
    for _ in range(epocas):
        ordem = rng.permutation(len(y))
        for inicio in range(0, len(y), tamanho_lote):
            lote = ordem[inicio:inicio + tamanho_lote]
            erro = (X[lote] @ w + b) - y[lote]
            w = w - taxa * (2 / len(lote)) * (X[lote].T @ erro)
            b = b - taxa * 2 * np.mean(erro)
        historico.append(np.mean((X @ w + b - y) ** 2))
    return w, b, historico

Resumo

Conceito O que é Em código
gradiente do MSE quanto o custo muda quando cada parâmetro muda 2 * np.mean(erro * x), ou (2 / n) * (X.T @ erro) com várias features
passo andar contra o gradiente w -= taxa * dw
época uma passada por todos os dados for epoca in range(epocas):
taxa de aprendizado o tamanho do passo: pequena demais é lenta, grande demais diverge escolhida na mão (exercício 4)
padronizar deixar as features na mesma escala, pro terreno virar uma tigela redonda (X - X.mean(axis=0)) / X.std(axis=0)
mini-batch estimar o gradiente com um pedaço aleatório dos dados ordem = rng.permutation(n) e fatias de ordem

A receita, que vale para quase todo modelo de ML: previsão → erro → custo → gradiente → passo, repetida muitas vezes.