← Voltar pra playlist

Da Classificação pra Regressão, e a Solução em Uma Linha

Aula 2c e 2d. A estrutura de código muda muito pouco, mas o significado muda bastante: sai o sign(), entra a previsão contínua, e no fim uma conta fechada substitui centenas de iterações.

Trocando classificação por regressão: só tira o sign()

def createRegressionDataset(n=20):
  X = np.random.rand(n,1)
  coef = 0.7
  intercept = 0.2
  noise = np.random.randn(n,1) * 0.1
  y = X * coef + intercept + noise
  return X, y.reshape(-1)

Diferente dos datasets anteriores, y aqui não é mais -1 ou +1, é um número contínuo, 0.7 * x + 0.2 mais um ruído gaussiano pequeno. Não tem classe pra acertar, tem uma reta pra encontrar.

class LinearRegression(BaseEstimator, ClassifierMixin):
  def fit(self, X, y):
    self.w_ = np.random.rand(X.shape[1])
    self.b_ = np.random.rand()
    for _ in range(self.max_iter):
      y_pred = self.predict(X)
      error = y - y_pred
      self.w_ += np.dot(X.T, error) * self.learning_rate
      self.b_ += np.sum(error) * self.learning_rate
    return self

  def predict(self, X):
    return X @ self.w_ + self.b_

Repara como isso é quase idêntico ao perceptron vetorizado do post passado: mesmo X.T @ error pra atualizar peso, mesmo sum(error) pra atualizar bias. As duas diferenças são pequenas no código, mas mudam o significado inteiro: primeiro, predict não passa mais por sign(), a previsão fica contínua (ativação linear, a mesma ideia que o Aggarwal descreve como a função de ativação mais simples que existe, Φ(v)=v\Phi(v) = v). Segundo, apareceu um learning_rate multiplicando a atualização. No perceptron essa taxa de aprendizado nem existia, era implicitamente 1. O Aggarwal chama isso de peculiaridade interessante do perceptron, dá pra fixar a taxa em 1 porque ela só reescala o peso, não muda a direção do ajuste. Aqui, com erro contínuo em vez de erro {-2,0,+2}, a magnitude do ajuste pode ficar grande ou pequena demais dependendo da escala do erro, e por isso a taxa de aprendizado explícita vira necessária pra controlar o tamanho do passo, o mesmo assunto que já rendeu um post inteiro na outra playlist.

Saída: RMSE 0.0948, pesos [0.706], bias 0.204. Bem perto do gerador de verdade (0.7 e 0.2), a diferença é só o ruído que foi embutido de propósito no dataset.

Uma observação honesta: a célula seguinte do notebook original chama createDataset (não createRegressionDataset), accuracy_score e plotHyperplan, nomes que não existem nesse notebook, só no da aula passada. Isso só rodou porque o Colab do professor estava com a sessão anterior ainda na memória (variável reaproveitada de aula pra aula). Rodando esse notebook do zero, essa célula quebraria. Não reproduzo esse pedaço aqui, porque ele não testa de fato o modelo de regressão que acabou de ser treinado.

A equação normal: a mesma pergunta, resolvida sem iterar

def include_bias(X):
  return np.hstack((np.ones((X.shape[0],1)), X))

class NormalEquation(BaseEstimator, ClassifierMixin):
  def fit(self, X, y):
    X = include_bias(X)
    self.w_ = np.linalg.pinv(X) @ y
    return self

  def predict(self, X):
    X = include_bias(X)
    return X @ self.w_

Duas coisas novas aqui. A primeira é o include_bias: gruda uma coluna de 1s na frente de X. Isso é literalmente o truque que eu descrevi em palavras no post passado, o bias como peso de uma variável fantasma que vale sempre 1, só que agora escrito como código de verdade em vez de um b_ separado. Com essa coluna extra, o bias vira só mais um peso normal dentro de w_.

A segunda é o NormalEquation em si: nada de laço, nada de learning_rate, só np.linalg.pinv(X) @ y. Essa é a solução fechada que eu já explorei com detalhe lá no Reconhecimento de Padrões, a mesma pergunta ("qual reta minimiza o erro quadrático") resolvida direto, numa única conta, em vez de descida passo a passo. A diferença técnica aqui é o método: lá eu implementei via eliminação de Gauss-Jordan com pivotamento. O professor usa a pseudo-inversa (pinv), que resolve por decomposição SVD por baixo dos panos. A vantagem da pseudo-inversa é ela nunca travar: mesmo se X tiver colunas redundantes (linearmente dependentes) e a matriz X^T X não puder ser invertida da forma tradicional, pinv ainda devolve uma resposta válida (a de menor norma entre as infinitas soluções possíveis). Eliminação de Gauss-Jordan pura, nesse mesmo caso, simplesmente quebra.

Saída: RMSE 0.12364226682065012, pesos [0.25402951 0.61056989] (bias e coeficiente, nessa ordem, por causa do include_bias).

E o gradiente descendente da célula anterior, no mesmo dataset, chegou em RMSE 0.12364226680246916, pesos praticamente idênticos. Duas contas completamente diferentes (uma iterativa, uma fechada) convergindo pro mesmíssimo lugar, até a sétima casa decimal. Reproduzi isso eu mesmo com um dataset seedado (np.random.seed(7)) pra confirmar que não foi coincidência de uma rodada só: GD deu pesos [0.6478, bias 0.2041], equação normal deu [bias 0.2041, coef 0.6478], RMSE idêntico até a nona casa decimal nos dois.

Interativo: ache a reta você mesmo

Antes de ver a máquina resolver, tenta resolver na mão. Esse é o mesmo dataset seedado de cima (20 pontos reais gerados por coef=0.7, intercept=0.2 mais ruído), mexe nos sliders de w e b e observa o erro total mudando ao vivo.

Erro total: 10.3

Fechando

O que eu já sabiaO que essa aula assentou
Perceptron classifica com sign()Tirar o sign() da mesma estrutura de código transforma classificação em regressão, quase sem mexer em mais nada
Bias é peso de uma variável fantasma de valor 1Isso vira código explícito com include_bias, em vez de um b_ separado
Equação normal já resolvi via Gauss-Jordanpinv (pseudo-inversa via SVD) resolve a mesma conta e ainda funciona quando X^T X não é invertível

Aplicação Prática

Testei gradiente descendente contra a equação normal no dataset real de 50 casas que já apareceu na outra playlist, usando só square_feet pra prever price, sem normalizar nada primeiro.

w, b = fit_gd(X, y, max_iter=1000, learning_rate=0.01)  # sem normalizar

Saída: o peso vira -inf já na iteração 71. O gradiente descendente diverge completamente.

Esperado: é a mesma lição da feature scaling na outra playlist, só que reencontrada aqui dentro do contexto de rede neural. square_feet vive na casa das centenas e price na casa das centenas de milhares, então o gradiente é enorme e o passo de 0.01 explode.

AbordagemRMSE
Gradiente descendente, dado normalizado101878.42
Equação normal, dado cru (sem normalizar)101878.42

Normalizando square_feet antes de rodar gradiente descendente, ele converge e chega exatamente no mesmo RMSE que a equação normal encontra direto no dado cru, sem precisar normalizar nada. Faz sentido: a equação normal resolve o sistema linear de uma vez só, então a escala das variáveis afeta só a estabilidade numérica da conta, não se ela converge ou não (diferente do gradiente descendente, que literalmente pode divergir se o passo for grande demais pra escala do dado).