← Voltar pra playlist

Generalizando pra Qualquer Profundidade: o Mesmo Loop Resolve Tudo

No post passado, o pulo do gato apareceu escrito à mão, pra exatamente uma camada escondida: calcular o erro da saída, repartir ele entre os dois neurônios escondidos, multiplicar pela folga de cada um. Funciona, mas só serve pra essa arquitetura específica. Essa aula pega esse mesmo pulo do gato e reescreve ele como um laço, um for que roda uma vez por camada, pra frente no forward e de trás pra frente no backward. O resultado funciona pra zero camadas escondidas, pra uma, pra duas, pra quantas você quiser, sem trocar uma linha de código.

De duas variáveis fixas pra duas listas

class MultiLayer(BaseEstimator, ClassifierMixin):
  def __init__(self, n_hidden=[2], max_iter=20000, learning_rate=0.001):
    self.max_iter = max_iter
    self.learning_rate = learning_rate
    self.n_hidden = n_hidden
    self.activation = np.tanh

  def forward(self, X):
    self.A = []
    self.Z = []
    AUX = X.copy()
    for W in self.Ws:
      self.A.append(include_bias(AUX))
      self.Z.append(self.A[-1] @ W)
      AUX = self.activation(self.Z[-1])
    return AUX

  def backward(self, y, y_pred):
    grads = []
    output_delta = y_pred - y
    output_grad = self.A[-1].T @ output_delta
    grads.append(output_grad)
    for i in range(len(self.Ws)-1, 0, -1):
      tanh_grad = (1 - np.tanh(self.Z[i-1])**2)
      input_delta = output_delta @ self.Ws[i][1:].T * tanh_grad
      grad = self.A[i-1].T @ input_delta
      grads.insert(0, grad)
      output_delta = input_delta.copy()
    for i in range(len(self.Ws)):
      self.Ws[i] -= self.learning_rate * grads[i]
    return self

  def fit(self, X, y):
    self.Ws = []
    previous_output = X.shape[1]
    for n in self.n_hidden:
      self.Ws.append(np.random.randn(previous_output+1, n))
      previous_output = n
    if len(y.shape) == 1:
      y = y.reshape(-1,1)
    self.Ws.append(np.random.randn(previous_output+1, y.shape[1]))
    for _ in range(self.max_iter):
      logits = self.forward(X)
      self.backward(y, logits)
    return self

Repara no __init__: ihw e how do post passado viraram uma coisa só, self.Ws, uma lista de matrizes, uma por camada (todas as escondidas mais a de saída). n_hidden também virou lista: [2] significa uma camada escondida com 2 neurônios, [10, 10] significa duas camadas de 10, [] significa nenhuma camada escondida. O fit monta essa lista percorrendo n_hidden e empilhando uma matriz de pesos pra cada tamanho, sempre lembrando o previous_output pra saber quantas linhas a próxima matriz precisa ter, e no fim adiciona mais uma matriz pra camada de saída.

forward: o mesmo cálculo, guardado camada por camada

def forward(self, X):
  self.A = []
  self.Z = []
  AUX = X.copy()
  for W in self.Ws:
    self.A.append(include_bias(AUX))
    self.Z.append(self.A[-1] @ W)
    AUX = self.activation(self.Z[-1])
  return AUX

AUX começa como o dado bruto e vai virando, a cada volta do laço, a saída da camada anterior já ativada, exatamente como H virava a entrada da camada de saída no post passado, só que agora pode acontecer de novo, e de novo, uma vez por camada. A diferença que importa: self.A e self.Z guardam uma cópia de cada entrada-com-bias e cada pré-ativação ao longo do caminho, não só da última. Isso não é por acaso, é exatamente o que o backward vai precisar pra repartir o erro por todas as camadas, não só pela última.

Um detalhe honesto: AUX = self.activation(self.Z[-1]) roda em toda camada dentro do laço, inclusive a última. Isso significa que a saída final também passa por tanh agora, diferente do post passado, onde a camada de saída ficava sem ativação nenhuma (logits = self.Hb @ self.how, puro linear). Não muda o sinal da predição (sign(tanh(z)) é sempre igual a sign(z), porque tanh é crescente e cruza o zero exatamente onde z cruza), mas é uma simplificação que o professor manteve tanto ao generalizar quanto no cálculo do gradiente do backward logo abaixo.

backward: o pulo do gato, repetido de trás pra frente

def backward(self, y, y_pred):
  grads = []
  output_delta = y_pred - y
  output_grad = self.A[-1].T @ output_delta
  grads.append(output_grad)
  for i in range(len(self.Ws)-1, 0, -1):
    tanh_grad = (1 - np.tanh(self.Z[i-1])**2)
    input_delta = output_delta @ self.Ws[i][1:].T * tanh_grad
    grad = self.A[i-1].T @ input_delta
    grads.insert(0, grad)
    output_delta = input_delta.copy()
  for i in range(len(self.Ws)):
    self.Ws[i] -= self.learning_rate * grads[i]
  return self

Duas coisas mudam de cara em relação ao post passado, e nenhuma delas é matemática nova. Primeiro, o sinal virou: era erro = y - y_pred e self.how += taxa * grad, agora é output_delta = y_pred - y e self.Ws[i] -= taxa * grads[i]. É a mesma conta multiplicada por -1 dos dois lados, ir na direção que diminui o erro (pred - y) é a mesma coisa que ir na direção que aumenta o acerto (y - pred), só reescrita ao contrário. Segundo, e essa é a generalização de verdade: o bloco que era só três linhas fixas virou um laço, for i in range(len(self.Ws)-1, 0, -1), que começa na penúltima camada e anda pra trás até a primeira, repetindo, camada por camada, exatamente o mesmo pulo do gato do post passado: pega o output_delta que chegou da camada seguinte, reparte ele através da transposta dos pesos dessa camada (self.Ws[i][1:].T, sem o bias), multiplica pela derivada da tanh (quanta folga essa camada ainda tinha), e o resultado (input_delta) vira o output_delta da próxima volta do laço, uma camada mais pra trás. Cada volta do laço faz, pra uma camada, exatamente o que o post passado fez, à mão, pra uma única camada escondida.

E repara no caso mais simples de todos: se self.Ws tem só uma matriz (n_hidden=[], sem camada escondida nenhuma), o laço range(0, 0, -1) não roda nem uma vez. grads fica só com o output_grad calculado antes do laço, e o fit treina essa única matriz de pesos direto em cima do dado bruto. Isso não é um caso especial tratado à parte no código, é o mesmo laço, só que vazio: a arquitetura mais simples de todas (um perceptron sem camada escondida) é só o caso len(self.Ws) == 1 dessa fórmula geral, sem precisar de nenhum if a mais.

Testando o caso vazio: OR e AND sem camada escondida

O notebook confirma isso na prática, treinando MultiLayer(n_hidden=[]) em cima de OR e de AND, os dois linearmente separáveis, diferente do XOR:

X, y = createLogicalDataset(func= lambda a, b: bool(a) or bool(b))
model = MultiLayer(n_hidden=[])
model.fit(X, y)

Saída: acurácia 1.0 (OR) e acurácia 1.0 (AND), cada um com sua própria fronteira linear.

Reproduzi o caso do OR eu mesmo, pra você ver o n_hidden=[] treinando ao vivo:

Uma reta só, treinada pela mesma regra delta desde o primeiro post da playlist, resolve o OR sem precisar de nenhuma camada extra. A generalização não trocou a ferramenta, só deixou claro que "sem camada escondida" é um ponto na mesma reta de opções, não uma exceção.

Testando o caso difícil: quando uma reta não basta

Fui um passo além do notebook aqui: gerei um dataset bem mais complicado que os quatro cantos do XOR, dez blobs espalhados no plano, cada um rotulado pela paridade do índice do seu centro (blobs 0, 2, 4... numa classe, 1, 3, 5... na outra), a mesma receita do make_hard_dataset da aula, só que fixei o sorteio pra poder comparar arquiteturas diferentes no mesmo dataset:

def make_hard_dataset(n=100, centers=10, cluster_std=0.5):
  X, y = make_blobs(n_samples=n, centers=centers, cluster_std=cluster_std)
  y = np.where(y%2 == 0, 1, -1)
  return X, y

Testa você mesmo, trocando de arquitetura no mesmo dado (a cada troca, os pesos são sorteados de novo e o treino recomeça do zero):

Sem camada escondida, o treino empaca em torno de 81% de acurácia, não importa quantas vezes você clique em treinar mais: dez blobs espalhados alternando classe não cabem atrás de uma única reta, não importa quão bem ajustada ela seja. Troca pra 1 camada de 10 e, depois de alguns cliques, a acurácia sobe até 100%: dez direções combinadas conseguem contornar cada blob individualmente. 2 camadas de 10 chegam no mesmo 100%, só que geralmente levando um pouco mais de cliques pra estabilizar, mais uma camada de composição não ajudou nesse problema específico (ele já tinha "espaço" suficiente pra ser resolvido com uma camada só), mas também não atrapalhou, o mesmo laço genérico dá conta dos dois jeitos igual.

Fechando

O que eu já sabiaO que essa aula assentou
O pulo do gato reparte o erro através de UMA camada escondidaO mesmo pulo do gato, num laço, reparte o erro através de QUALQUER número de camadas, uma de cada vez
Sem camada escondida, o modelo é um perceptron/AdalineIsso não é um caso à parte no código, é só o laço rodando zero vezes
Uma reta resolve problemas linearmente separáveis (OR, AND)Um dataset com dez blobs alternados não é linearmente separável, e nenhuma quantidade de treino resolve isso sem profundidade

Aplicação Prática

Na aula passada, variei a largura de uma única camada escondida no dataset de duas luas e vi a acurácia subir rápido e depois estabilizar. Essa aula generalizou pra profundidade, então a pergunta natural agora é a inversa: será que empilhar uma segunda camada escondida ajuda nesse mesmo dataset? Treinei MultiLayer no mesmo dataset de duas luas (mesmos 300 pontos, mesma divisão 70/30, 3000 passos, 10 sementes por arquitetura), comparando zero, uma e duas camadas escondidas de 20 neurônios cada:

ArquiteturaAcurácia de teste (média de 10 sementes)
Sem camada escondida0.889
1 camada de 200.967
2 camadas de 200.937

Sem camada escondida, o modelo já chega em 0.889, mais alto do que eu esperava: diferente do XOR, as duas luas não são um caso tão hostil pra uma reta só, elas ficam deslocadas uma da outra, então uma diagonal bem colocada já acerta a maioria dos pontos, só erra nas bordas onde as duas formas se encaixam. Uma camada de 20 sobe pra 0.967, o mesmo número que vi na aula passada, a curva consegue se dobrar em volta de cada lua de verdade. Mas empilhar uma segunda camada de 20 não ajuda, piora: 0.937, abaixo até da rede de uma camada só. Isso não é erro de digitação nem falta de sorte no sorteio (dá pra reproduzir com sementes diferentes): o erro precisa atravessar duas derivadas de tanh encadeadas pra chegar na primeira camada, em vez de uma só, e com o mesmo número fixo de passos de treino esse caminho mais longo nem sempre termina de convergir. Empilhar camada não é grátis: cada camada a mais é mais um degrau pro gradiente descer até lá atrás, e se o treino não rodar tempo suficiente pra compensar, profundidade extra pode atrapalhar em vez de ajudar. No dataset de blobs ali em cima, com um problema genuinamente mais difícil, a segunda camada não trouxe prejuízo porque tinha motivo real pra existir; aqui, onde uma camada já dava conta, ela só tornou o treino mais lento de convergir.