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, ). 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], bias0.204. Bem perto do gerador de verdade (0.7e0.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 doinclude_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á sabia | O 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 1 | Isso vira código explícito com include_bias, em vez de um b_ separado |
| Equação normal já resolvi via Gauss-Jordan | pinv (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
-infjá 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.
| Abordagem | RMSE |
|---|---|
| Gradiente descendente, dado normalizado | 101878.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).