A Camada Escondida Também Aprende (o Pulo do Gato do Backprop)
O post passado fechou com duas formas de montar a camada escondida de um MLP: escolher os pesos na mão, sabendo de antemão que retas iam separar o XOR, ou sortear eles aleatoriamente e confiar que, com neurônios suficientes, alguma combinação ia funcionar (o ELM). Nenhuma das duas aprende a camada escondida a partir do erro. Uma é fixa porque alguém já sabia a resposta, a outra é fixa porque nem tenta saber. Essa aula fecha essa lacuna: e se, em vez de escolher ou sortear, a camada escondida também pudesse se ajustar sozinha, olhando pro erro que ela mesma ajudou a causar? Essa pergunta tem nome, e o professor deixou ele escrito no meio do código.
Aula 5a: a última camada aprende com gradiente, não com pseudo-inversa
Antes de mexer na camada escondida, vale um passo intermediário: pegar a mesma arquitetura do post passado (duas retas escondidas fixas, escolhidas na mão) e treinar só a camada de saída, só que dessa vez com atualização passo a passo em vez da conta fechada da pseudo-inversa.
class LastLayer(BaseEstimator, ClassifierMixin):
def __init__(self, max_iter=100, learning_rate=0.01):
self.max_iter = max_iter
self.learning_rate = learning_rate
def fit(self, X, y):
self.Xb = include_bias(X)
self.ihw = np.array([[0.5,-1,1],[0.5,1,-1]]).T
self.pH = self.Xb @ self.ihw
self.H = np.tanh(self.pH)
self.Hb = include_bias(self.H)
if len(y.shape) == 1:
y = y.reshape(-1,1)
self.how = np.random.uniform(-1, 1, size=(self.Hb.shape[1], y.shape[1]))
for _ in range(self.max_iter):
logits = self.Hb @ self.how
y_pred = np.sign(logits)
error = y - y_pred
delta = self.Hb.T @ error
self.how += self.learning_rate * delta
return self
Repara na primeira metade do fit: ihw continua sendo exatamente [[0.5,-1,1],[0.5,1,-1]], as mesmas duas retas escondidas de sempre, escolhidas na mão e nunca tocadas dentro do laço. O que muda é a segunda metade: how não sai mais de uma pseudo-inversa resolvida numa linha só, ele nasce aleatório (np.random.uniform) e vai sendo corrigido, iteração após iteração, por delta = self.Hb.T @ error. Se esse formato parece familiar, é porque é a mesma regra delta do Adaline, entrada.T @ erro, só que a "entrada" aqui não é mais x0/x1 crus, é Hb, a saída da camada escondida já com bias. O dado que essa conta enxerga já passou pela transformação de espaço; a regra de ajuste em cima dele é a mesma de sempre.
Saída: acurácia 1.0, pesos de saída
[[0.439], [-0.529], [-0.514]].
O resultado não devia surpreender: como já vi no post passado, depois de passar pelas duas retas escondidas fixas, o XOR vira linearmente separável em cima de H. Se um problema é linearmente separável, uma regra delta rodando tempo suficiente encontra uma fronteira que separa ele, pouco importa se a fronteira final foi resolvida de uma vez (pseudo-inversa) ou aos poucos (gradiente). A pergunta interessante não é "isso funciona?", é a próxima.
Aula 5b: e se a camada escondida também pudesse errar e se corrigir?
O notebook reaproveita a mesma classe LastLayer ali de cima como ponto de partida (mesmo resultado, acurácia 1.0, pesos de saída diferentes porque o sorteio inicial de how muda a cada rodada), só que agora ela ganha dois métodos novos, separados, chamados de dentro do fit:
class SingleHiddenLayer(BaseEstimator, ClassifierMixin):
def __init__(self, n_hidden=2, max_iter=10000, learning_rate=0.005):
self.max_iter = max_iter
self.learning_rate = learning_rate
self.n_hidden = n_hidden
self.activation = np.tanh
def forward(self, X):
self.Xb = include_bias(X)
self.pH = self.Xb @ self.ihw
self.H = self.activation(self.pH)
self.Hb = include_bias(self.H)
logits = self.Hb @ self.how
return logits
def backward(self, y, y_pred):
ho_delta = y - y_pred
ho_grad = self.Hb.T @ ho_delta
### PULO DO GATO ###
tanh_grad = (1 - self.H**2)
ih_delta = ho_delta @ self.how[1:].T * tanh_grad
### #### ## #### ###
ih_grad = self.Xb.T @ ih_delta
self.ihw += self.learning_rate * ih_grad
self.how += self.learning_rate * ho_grad
return self
def fit(self, X, y):
self.ihw = np.random.uniform(-1, 1, size=(X.shape[1]+1, self.n_hidden))
if len(y.shape) == 1:
y = y.reshape(-1,1)
self.how = np.random.uniform(-1, 1, size=(self.ihw.shape[1]+1, y.shape[1]))
for _ in range(self.max_iter):
logits = self.forward(X)
self.backward(y, logits)
return self
Duas mudanças de uma vez em relação à LastLayer. Primeiro, ihw também nasce sorteado (np.random.uniform) dentro do fit, não mais hard-coded no __init__: ninguém mais escolhe as retas escondidas na mão. Segundo, e essa é a parte que interessa: dentro do backward, depois de calcular ho_grad pra camada de saída (exatamente a mesma conta de sempre, Hb.T @ erro), tem mais três linhas, que o próprio professor cercou com um comentário digno de destaque no meio do código.
O que essas três linhas fazem, sem pular nenhuma
ho_delta = y - y_pred é o erro da saída, um número por ponto, a mesma diferença entre certo e previsto de sempre. A pergunta que essas três linhas respondem é: cada neurônio escondido causou quanto desse erro?
ih_delta = ho_delta @ self.how[1:].T * tanh_grad
Pensa em cada neurônio escondido como alguém que dá um palpite, e a camada de saída como quem pesa os dois palpites (how[1:], os pesos de saída sem o bias) pra chegar numa decisão final. Se a decisão final errou, faz sentido que o palpite que mais pesou na hora de decidir carregue mais culpa, e o que quase não pesou (peso perto de zero) saia quase isento. É exatamente essa reparte que ho_delta @ self.how[1:].T calcula: pega o erro da saída e distribui ele entre os dois neurônios escondidos, na proporção exata dos pesos que a camada de saída deu a cada um. Repara na simetria bonita: na ida (forward), how pega dois números escondidos e combina eles num só, de saída. Na volta, ele faz o caminho inverso, pega um número de erro e reparte ele em dois, um pra cada neurônio que ajudou a gerar aquela saída. Por isso o .T: é a mesma matriz, usada ao contrário.
Só que reparte a culpa não basta. Um neurônio só consegue de fato mudar de opinião se ainda tinha "folga" pra isso, e é aí que entra tanh_grad = (1 - self.H**2), a derivada da tanh calculada em cima da própria ativação H (uma identidade da tanh garante que tanh'(z) = 1 - tanh(z)², então não precisa guardar a pré-ativação separada). Quando um neurônio está saturado, H perto de +1 ou -1, essa derivada fica perto de zero: por mais culpa que ele carregue, o ajuste que ele recebe é minúsculo, porque ele já tomou a decisão dele com convicção total e mexer nos pesos dele agora quase não move a saída. Quando H está perto de 0, no meio do caminho, a derivada é máxima (1): esse neurônio ainda está "indeciso", e é justamente aí que um empurrão faz mais diferença.
Multiplicando as duas coisas (culpa repartida × folga que sobrou), chega o ih_delta, e daí em diante é o Adaline de sempre: ih_grad = self.Xb.T @ ih_delta é a mesma regra delta, entrada.T @ erro, agora aplicada por neurônio escondido, usando o erro que acabou de ser calculado especialmente pra ele.
Isso tem nome em matemática: multiplicar derivadas de funções encadeadas (a saída depende de how, que depende de H, que depende de ihw) pra achar o quanto lá no fundo da cadeia afeta o resultado final é a regra da cadeia. ho_delta @ self.how[1:].T é a derivada da saída em relação a H; tanh_grad é a derivada de H em relação à pré-ativação escondida; multiplicar as duas, ponto a ponto, é literalmente aplicar a regra da cadeia pra descobrir o quanto a pré-ativação de cada neurônio escondido pesou no erro final. Backpropagation não inventa uma matemática nova, ele só organiza a regra da cadeia numa ordem prática: calcula do fim pra trás, reaproveitando a mesma conta de cada camada pra achar a de trás dela.
É esse bloco de três linhas, achar o erro que cabe a cada neurônio escondido e ajustar ele só na medida da folga que ele tinha, que o professor batizou no próprio código de
### PULO DO GATO ###. Ele não está exagerando: isso é, literalmente, a ideia inteira por trás de backpropagation. Treinar só a camada de saída (Aula 5a) é regra delta pura. Treinar a rede inteira é regra delta aplicada camada por camada, cada uma usando o erro que a camada seguinte repassou pra ela. Não tem mágica nova nenhuma, só a mesma conta, encadeada.
Saída: acurácia 1.0, pesos de saída
[[-1.396], [2.145], [-1.883]].
Vendo o pulo do gato acontecer
Testa você mesmo. O modelo abaixo começa com a caixinha desmarcada, exatamente a Aula 5a: as duas retas escondidas nascem já nos valores escolhidos na mão, e ficam fixas o tempo todo, só a camada de saída se ajusta a cada clique. Marca a caixinha e treina de novo: agora virou a Aula 5b, e as duas retas escondidas também se mexem a cada passo, puxadas pelo erro que a camada de saída manda de volta pra elas.
Com a camada escondida congelada, a região de decisão sempre parte das mesmas duas retas paralelas, só a inclinação da fronteira final entre elas muda de clique pra clique. Com a caixinha marcada, repara que as próprias retas escondidas mudam de posição enquanto treina, às vezes até cruzam, o gradiente que chega nelas via ih_delta está genuinamente redesenhando a transformação de espaço, não só reajustando o que vem depois dela. E como o sorteio inicial de ihw/how é aleatório, às vezes o treino empaca num mínimo ruim antes de chegar em acurácia 1.0, isso também é real: gradiente descendente não tem a garantia de solução exata que a pseudo-inversa do ELM tinha. Se travar, o botão de reiniciar sorteia pesos novos e tenta de novo.
Fechando
| O que eu já sabia | O que essa aula assentou |
|---|---|
| Regra delta treina a camada de saída sozinha (Adaline, ELM) | A mesma regra delta treina a camada escondida também, desde que o erro chegue até ela |
how combina os neurônios escondidos numa saída (forward) | A mesma matriz how, transposta, reparte o erro de volta entre os neurônios (backward), o pulo do gato |
| Derivada da tanh mede o quanto uma ativação satura | Essa derivada também mede o quanto um neurônio escondido ainda tem "folga" pra ser corrigido |
Aplicação Prática
No post passado, testei o ELM (camada escondida sorteada, resolvida por pseudo-inversa) no dataset de duas luas e vi a acurácia subir até 50 neurônios e cair de novo em 100, overfitting clássico. Agora que sei treinar a camada escondida de verdade, cabe a pergunta óbvia: será que treinar bate sortear? Rodei o mesmo SingleHiddenLayer (learning_rate ajustada pra cada tamanho, 3000 passos, 10 sementes de inicialização por tamanho) no mesmo dataset, mesma divisão treino/teste 70/30:
| Neurônios escondidos | ELM (post passado) | Backprop (esta aula) |
|---|---|---|
| 1 | 0.714 | 0.880 |
| 5 | 0.881 | 0.969 |
| 20 | 0.927 | 0.968 |
| 50 | 0.930 | 0.968 |
| 100 | 0.843 | 0.976 |
Duas coisas saltam aos olhos. A primeira: com só 1 neurônio, o backprop já bate 0.88, bem acima do 0.71 do ELM, porque aqui aquele único neurônio não fica preso numa direção sorteada ao acaso, o gradiente empurra ele pra a direção que realmente separa as luas. A segunda, mais sutil: o backprop não despenca em 100 neurônios como o ELM despencou. A pseudo-inversa do ELM sempre encontra a solução exata pro conjunto de treino, ajusta perfeitamente até o ruído, com rede grande demais isso vira overfitting puro. O gradiente descendente, rodando um número fixo de passos, quase nunca chega nessa solução exata, ele para no meio do caminho, e parar no meio do caminho, aqui, funciona como um freio contra decorar o treino que a conta fechada da pseudo-inversa não tem.
Agora treina ao vivo, você mesmo, no navegador: o modelo abaixo é o mesmo SingleHiddenLayer, rodando de verdade em cima dos 300 pontos das duas luas (os mesmos que gerei pra tabela acima, treino e teste juntos, já que aqui o ponto é ver o gradiente trabalhar, não medir generalização de novo):
Clica em "treinar mais passos" algumas vezes: dá pra ver a fronteira sair de um emaranhado praticamente aleatório e se curvar em volta das duas luas, sem que ninguém tenha escolhido essas 20 direções escondidas na mão nem sorteado elas de vez, o próprio gradiente as encontrou. A acurácia mostrada aqui é em cima do próprio dataset de treino ao vivo, não é diretamente comparável linha a linha com a tabela acima (que mediu em teste separado), mas o comportamento é o mesmo: poucos cliques já resolvem a maior parte, e depois de um tempo a curva satura.