Phase 03 - Lesson 04

Funções de Ativação

This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.

Sem não linearidade, a sua rede de 100 camadas não passa de uma multiplicação de matrizes sofisticada. As ativações são os portões que permitem que as redes neurais pensem em curvas.

Tipo: Build Linguagens: Python Pré-requisitos: Lição 03.03 (Backpropagation) Tempo: ~75 minutos

Objetivos de Aprendizagem

  • Implementar sigmoid, tanh, ReLU, Leaky ReLU, GELU, Swish e softmax com suas derivadas do zero
  • Diagnosticar o problema do gradiente que desaparece medindo as magnitudes das ativações ao longo de mais de 10 camadas com diferentes ativações
  • Detectar neurônios mortos em uma rede ReLU e explicar por que a GELU evita esse modo de falha
  • Selecionar a função de ativação correta para uma dada arquitetura (transformer, CNN, RNN, camada de saída)

O Problema

Empilhe duas transformações lineares: y = W2(W1x + b1) + b2. Expanda: y = W2W1x + W2b1 + b2. Isso é apenas y = Ax + c -- uma única transformação linear. Não importa quantas camadas lineares você empilhe, o resultado colapsa em uma única multiplicação de matrizes. A sua rede de 100 camadas tem o mesmo poder de representação que uma única camada.

Isso não é uma curiosidade teórica. Significa que uma rede linear profunda literalmente não consegue aprender XOR, não consegue classificar um conjunto de dados em espiral, não consegue reconhecer um rosto. Sem funções de ativação, a profundidade é uma ilusão.

As funções de ativação quebram a linearidade. Elas deformam a saída de cada camada através de uma função não linear, dando à rede a capacidade de curvar fronteiras de decisão, aproximar funções arbitrárias e de fato aprender. Mas escolha a ativação errada e os seus gradientes desaparecem até zero (sigmoid em redes profundas), explodem até o infinito (ativações ilimitadas sem inicialização cuidadosa), ou os seus neurônios morrem permanentemente (ReLU com grandes vieses negativos). A escolha da função de ativação determina diretamente se a sua rede aprende ou não.

O Conceito

Por Que a Não Linearidade É Necessária

A multiplicação de matrizes é componível. Multiplicar um vetor pela matriz A e depois pela matriz B é idêntico a multiplicar por AB. Isso significa que empilhar dez camadas lineares é matematicamente equivalente a uma única camada linear com uma matriz grande. Todos esses parâmetros, toda essa profundidade -- desperdiçados. Você precisa de algo para quebrar a cadeia. É isso que as funções de ativação fazem.

Aqui está a prova. Uma camada linear computa f(x) = Wx + b. Empilhe duas:

Layer 1: h = W1 * x + b1
Layer 2: y = W2 * h + b2

Substitua:

y = W2 * (W1 * x + b1) + b2
y = (W2 * W1) * x + (W2 * b1 + b2)
y = A * x + c

Uma camada. Insira uma ativação não linear g() entre as camadas:

h = g(W1 * x + b1)
y = W2 * h + b2

Agora a substituição quebra. W2 * g(W1 * x + b1) + b2 não pode ser reduzido a uma única transformação linear. A rede consegue representar funções não lineares. Cada camada adicional com uma ativação acrescenta capacidade de representação.

Sigmoid

A função de ativação original para redes neurais.

sigmoid(x) = 1 / (1 + e^(-x))

Faixa de saída: (0, 1). Suave, diferenciável, mapeia qualquer número real para um valor semelhante a uma probabilidade.

A derivada:

sigmoid'(x) = sigmoid(x) * (1 - sigmoid(x))

O valor máximo dessa derivada é 0,25, ocorrendo em x = 0. Na backpropagation, os gradientes se multiplicam ao longo das camadas. Dez camadas de sigmoid significam que o gradiente é multiplicado por no máximo 0,25 dez vezes:

0.25^10 = 0.000000953674

Menos de um milionésimo do sinal original. Esse é o problema do gradiente que desaparece. Os gradientes nas primeiras camadas ficam tão pequenos que os pesos quase não se atualizam. A rede parece aprender -- a perda diminui nas camadas posteriores -- mas as primeiras camadas estão congeladas. Redes sigmoid profundas simplesmente não treinam.

Problema adicional: as saídas da sigmoid são sempre positivas (0 a 1), o que significa que os gradientes nos pesos têm sempre o mesmo sinal. Isso causa um zigue-zague durante o gradiente descendente.

Tanh

A versão centralizada da sigmoid.

tanh(x) = (e^x - e^(-x)) / (e^x + e^(-x))

Faixa de saída: (-1, 1). Centrada em zero, o que elimina o problema do zigue-zague.

A derivada:

tanh'(x) = 1 - tanh(x)^2

A derivada máxima é 1,0 em x = 0 -- quatro vezes melhor que a sigmoid. Mas o problema do gradiente que desaparece ainda existe. Para entradas positivas ou negativas grandes, a derivada se aproxima de zero. Dez camadas ainda esmagam o gradiente, apenas de forma menos agressiva.

ReLU: O Avanço

Rectified Linear Unit (Unidade Linear Retificada). Popularizada para deep learning por Nair e Hinton em 2010 (a função em si remonta ao trabalho de Fukushima de 1969), ela mudou tudo.

relu(x) = max(0, x)

Faixa de saída: [0, infinito). A derivada é trivialmente simples:

relu'(x) = 1  if x > 0
            0  if x <= 0

Sem gradiente que desaparece para entradas positivas. O gradiente é exatamente 1, repassado diretamente. É por isso que as redes profundas se tornaram treináveis -- a ReLU preserva a magnitude do gradiente ao longo das camadas.

Mas existe um modo de falha: o problema do neurônio morto. Se a entrada ponderada de um neurônio é sempre negativa (devido a um grande viés negativo ou a uma inicialização infeliz dos pesos), sua saída é sempre zero, seu gradiente é sempre zero e ele nunca se atualiza. Ele está permanentemente morto. Na prática, de 10 a 40% dos neurônios em uma rede ReLU podem morrer durante o treinamento.

Leaky ReLU

A correção mais simples para neurônios mortos.

leaky_relu(x) = x        if x > 0
                alpha * x if x <= 0

Onde alpha é uma constante pequena, tipicamente 0,01. O lado negativo tem uma pequena inclinação em vez de zero, então os neurônios mortos ainda recebem um sinal de gradiente e podem se recuperar.

GELU: O Padrão Moderno

Gaussian Error Linear Unit (Unidade Linear de Erro Gaussiano). Introduzida por Hendrycks e Gimpel em 2016. Ativação padrão no BERT, GPT e na maioria dos transformers modernos.

gelu(x) = x * Phi(x)

Onde Phi(x) é a função de distribuição cumulativa da distribuição normal padrão. A aproximação usada na prática:

gelu(x) ~= 0.5 * x * (1 + tanh(sqrt(2/pi) * (x + 0.044715 * x^3)))

A GELU é suave em todos os pontos, permite pequenos valores negativos (ao contrário da ReLU, que recorta de forma abrupta para zero) e tem uma interpretação probabilística: ela pondera cada entrada pela probabilidade de ela ser positiva sob uma distribuição gaussiana. Esse portão suave supera a ReLU em arquiteturas transformer porque proporciona um melhor fluxo de gradiente e evita completamente o problema do neurônio morto.

Swish / SiLU

Ativação auto-portada descoberta por Ramachandran et al. em 2017 por meio de busca automatizada.

swish(x) = x * sigmoid(x)

A Swish é formalmente x * sigmoid(x). O Google a descobriu por meio de busca automatizada no espaço de funções de ativação -- uma rede neural projetando partes de redes neurais.

Como a GELU, ela é suave, não monotônica e permite pequenos valores negativos. A diferença é sutil: a Swish usa a sigmoid para o portão enquanto a GELU usa a CDF gaussiana. Na prática, o desempenho é quase idêntico. A Swish é usada na EfficientNet e em alguns modelos de visão. A GELU domina nos modelos de linguagem.

Softmax: A Ativação de Saída

Não usada em camadas ocultas. A softmax converte um vetor de pontuações brutas (logits) em uma distribuição de probabilidade.

softmax(x_i) = e^(x_i) / sum(e^(x_j) for all j)

Cada saída fica entre 0 e 1. Todas as saídas somam 1. Isso a torna a ativação final padrão para classificação multiclasse. O maior logit recebe a maior probabilidade, mas, ao contrário de argmax, a softmax é diferenciável e preserva informação sobre a confiança relativa.

Comparação de Formatos

graph LR
    subgraph "Funções de Ativação"
        S["Sigmoid<br/>Faixa: (0,1)<br/>Satura em ambas as pontas"]
        T["Tanh<br/>Faixa: (-1,1)<br/>Centrada em zero"]
        R["ReLU<br/>Faixa: [0,inf)<br/>Neurônios mortos"]
        G["GELU<br/>Faixa: ~(-0.17,inf)<br/>Portão suave"]
    end
    S -->|"Gradiente que desaparece"| Problem["Redes profundas<br/>não treinam"]
    T -->|"Menos severo, mas<br/>ainda desaparece"| Problem
    R -->|"Gradiente = 1<br/>para x > 0"| Solution["Redes profundas<br/>treinam rápido"]
    G -->|"Gradiente suave<br/>em todo lugar"| Solution

Comparação de Fluxo de Gradiente

graph TD
    Input["Sinal de Entrada"] --> L1["Camada 1"]
    L1 --> L5["Camada 5"]
    L5 --> L10["Camada 10"]
    L10 --> Output["Saída"]

    subgraph "Gradiente na Camada 1"
        SigGrad["Sigmoid: ~0.000001"]
        TanhGrad["Tanh: ~0.001"]
        ReluGrad["ReLU: ~1.0"]
        GeluGrad["GELU: ~0.8"]
    end

Qual Ativação Quando

flowchart TD
    Start["O que você está construindo?"] --> Hidden{"Camadas ocultas<br/>ou saída?"}

    Hidden -->|"Camadas ocultas"| Arch{"Arquitetura?"}
    Hidden -->|"Camada de saída"| Task{"Tipo de tarefa?"}

    Arch -->|"Transformer / NLP"| GELU["Use GELU"]
    Arch -->|"CNN / Visão"| ReLU["Use ReLU ou Swish"]
    Arch -->|"RNN / LSTM"| Tanh["Use Tanh"]
    Arch -->|"MLP simples"| ReLU2["Use ReLU"]

    Task -->|"Classificação binária"| Sigmoid["Use Sigmoid"]
    Task -->|"Classificação multiclasse"| Softmax["Use Softmax"]
    Task -->|"Regressão"| Linear["Use Linear (sem ativação)"]

Construa

Passo 1: Implementar Todas as Funções de Ativação com Derivadas

Cada função recebe um único float e retorna um float. Cada função de derivada recebe a mesma entrada e retorna o gradiente.

import math

def sigmoid(x):
    x = max(-500, min(500, x))
    return 1.0 / (1.0 + math.exp(-x))

def sigmoid_derivative(x):
    s = sigmoid(x)
    return s * (1 - s)

def tanh_act(x):
    return math.tanh(x)

def tanh_derivative(x):
    t = math.tanh(x)
    return 1 - t * t

def relu(x):
    return max(0.0, x)

def relu_derivative(x):
    return 1.0 if x > 0 else 0.0

def leaky_relu(x, alpha=0.01):
    return x if x > 0 else alpha * x

def leaky_relu_derivative(x, alpha=0.01):
    return 1.0 if x > 0 else alpha

def gelu(x):
    return 0.5 * x * (1 + math.tanh(math.sqrt(2 / math.pi) * (x + 0.044715 * x ** 3)))

def gelu_derivative(x):
    phi = 0.5 * (1 + math.erf(x / math.sqrt(2)))
    pdf = math.exp(-0.5 * x * x) / math.sqrt(2 * math.pi)
    return phi + x * pdf

def swish(x):
    return x * sigmoid(x)

def swish_derivative(x):
    s = sigmoid(x)
    return s + x * s * (1 - s)

def softmax(xs):
    max_x = max(xs)
    exps = [math.exp(x - max_x) for x in xs]
    total = sum(exps)
    return [e / total for e in exps]

Passo 2: Visualizar Onde os Gradientes Morrem

Compute o gradiente em 100 pontos uniformemente espaçados de -5 a 5. Imprima um histograma de texto mostrando onde o gradiente de cada ativação fica próximo de zero.

def gradient_scan(name, derivative_fn, start=-5, end=5, n=100):
    step = (end - start) / n
    near_zero = 0
    healthy = 0
    for i in range(n):
        x = start + i * step
        g = derivative_fn(x)
        if abs(g) < 0.01:
            near_zero += 1
        else:
            healthy += 1
    pct_dead = near_zero / n * 100
    print(f"{name:15s}: {healthy:3d} healthy, {near_zero:3d} near-zero ({pct_dead:.0f}% dead zone)")

gradient_scan("Sigmoid", sigmoid_derivative)
gradient_scan("Tanh", tanh_derivative)
gradient_scan("ReLU", relu_derivative)
gradient_scan("Leaky ReLU", leaky_relu_derivative)
gradient_scan("GELU", gelu_derivative)
gradient_scan("Swish", swish_derivative)

Passo 3: Experimento do Gradiente que Desaparece

Faça a passagem direta (forward pass) de um sinal através de N camadas usando sigmoid vs ReLU. Meça como a magnitude da ativação muda.

import random

def vanishing_gradient_experiment(activation_fn, name, n_layers=10, n_inputs=5):
    random.seed(42)
    values = [random.gauss(0, 1) for _ in range(n_inputs)]

    print(f"\n{name} through {n_layers} layers:")
    for layer in range(n_layers):
        weights = [random.gauss(0, 1) for _ in range(n_inputs)]
        z = sum(w * v for w, v in zip(weights, values))
        activated = activation_fn(z)
        magnitude = abs(activated)
        bar = "#" * int(magnitude * 20)
        print(f"  Layer {layer+1:2d}: magnitude = {magnitude:.6f} {bar}")
        values = [activated] * n_inputs

vanishing_gradient_experiment(sigmoid, "Sigmoid")
vanishing_gradient_experiment(relu, "ReLU")
vanishing_gradient_experiment(gelu, "GELU")

Passo 4: Detector de Neurônios Mortos

Crie uma rede ReLU, passe entradas aleatórias por ela e conte quantos neurônios nunca disparam.

def dead_neuron_detector(n_inputs=5, hidden_size=20, n_samples=1000):
    random.seed(0)
    weights = [[random.gauss(0, 1) for _ in range(n_inputs)] for _ in range(hidden_size)]
    biases = [random.gauss(0, 1) for _ in range(hidden_size)]

    fire_counts = [0] * hidden_size

    for _ in range(n_samples):
        inputs = [random.gauss(0, 1) for _ in range(n_inputs)]
        for neuron_idx in range(hidden_size):
            z = sum(w * x for w, x in zip(weights[neuron_idx], inputs)) + biases[neuron_idx]
            if relu(z) > 0:
                fire_counts[neuron_idx] += 1

    dead = sum(1 for c in fire_counts if c == 0)
    rarely_fire = sum(1 for c in fire_counts if 0 < c < n_samples * 0.05)
    healthy = hidden_size - dead - rarely_fire

    print(f"\nDead Neuron Report ({hidden_size} neurons, {n_samples} samples):")
    print(f"  Dead (never fired):     {dead}")
    print(f"  Barely alive (<5%):     {rarely_fire}")
    print(f"  Healthy:                {healthy}")
    print(f"  Dead neuron rate:       {dead/hidden_size*100:.1f}%")

    for i, c in enumerate(fire_counts):
        status = "DEAD" if c == 0 else "WEAK" if c < n_samples * 0.05 else "OK"
        bar = "#" * (c * 40 // n_samples)
        print(f"  Neuron {i:2d}: {c:4d}/{n_samples} fires [{status:4s}] {bar}")

dead_neuron_detector()

Passo 5: Comparação de Treinamento -- Sigmoid vs ReLU vs GELU

Treine a mesma rede de duas camadas no conjunto de dados do círculo (pontos dentro de um círculo = classe 1, fora = classe 0) com três ativações diferentes. Compare a velocidade de convergência.

def make_circle_data(n=200, seed=42):
    random.seed(seed)
    data = []
    for _ in range(n):
        x = random.uniform(-2, 2)
        y = random.uniform(-2, 2)
        label = 1.0 if x * x + y * y < 1.5 else 0.0
        data.append(([x, y], label))
    return data


class ActivationNetwork:
    def __init__(self, activation_fn, activation_deriv, hidden_size=8, lr=0.1):
        random.seed(0)
        self.act = activation_fn
        self.act_d = activation_deriv
        self.lr = lr
        self.hidden_size = hidden_size

        self.w1 = [[random.gauss(0, 0.5) for _ in range(2)] for _ in range(hidden_size)]
        self.b1 = [0.0] * hidden_size
        self.w2 = [random.gauss(0, 0.5) for _ in range(hidden_size)]
        self.b2 = 0.0

    def forward(self, x):
        self.x = x
        self.z1 = []
        self.h = []
        for i in range(self.hidden_size):
            z = self.w1[i][0] * x[0] + self.w1[i][1] * x[1] + self.b1[i]
            self.z1.append(z)
            self.h.append(self.act(z))

        self.z2 = sum(self.w2[i] * self.h[i] for i in range(self.hidden_size)) + self.b2
        self.out = sigmoid(self.z2)
        return self.out

    def backward(self, target):
        error = self.out - target
        d_out = error * self.out * (1 - self.out)

        for i in range(self.hidden_size):
            d_h = d_out * self.w2[i] * self.act_d(self.z1[i])
            self.w2[i] -= self.lr * d_out * self.h[i]
            for j in range(2):
                self.w1[i][j] -= self.lr * d_h * self.x[j]
            self.b1[i] -= self.lr * d_h
        self.b2 -= self.lr * d_out

    def train(self, data, epochs=200):
        losses = []
        for epoch in range(epochs):
            total_loss = 0
            correct = 0
            for x, y in data:
                pred = self.forward(x)
                self.backward(y)
                total_loss += (pred - y) ** 2
                if (pred >= 0.5) == (y >= 0.5):
                    correct += 1
            avg_loss = total_loss / len(data)
            accuracy = correct / len(data) * 100
            losses.append(avg_loss)
            if epoch % 50 == 0 or epoch == epochs - 1:
                print(f"    Epoch {epoch:3d}: loss={avg_loss:.4f}, accuracy={accuracy:.1f}%")
        return losses


data = make_circle_data()

configs = [
    ("Sigmoid", sigmoid, sigmoid_derivative),
    ("ReLU", relu, relu_derivative),
    ("GELU", gelu, gelu_derivative),
]

results = {}
for name, act_fn, act_d_fn in configs:
    print(f"\n=== Training with {name} ===")
    net = ActivationNetwork(act_fn, act_d_fn, hidden_size=8, lr=0.1)
    losses = net.train(data, epochs=200)
    results[name] = losses

print("\n=== Final Loss Comparison ===")
for name, losses in results.items():
    print(f"  {name:10s}: start={losses[0]:.4f} -> end={losses[-1]:.4f} (improvement: {(1 - losses[-1]/losses[0])*100:.1f}%)")

Use

O PyTorch fornece todas elas tanto na forma funcional quanto na forma de módulo:

import torch
import torch.nn as nn
import torch.nn.functional as F

x = torch.randn(4, 10)

relu_out = F.relu(x)
gelu_out = F.gelu(x)
sigmoid_out = torch.sigmoid(x)
swish_out = F.silu(x)

logits = torch.randn(4, 5)
probs = F.softmax(logits, dim=1)

model = nn.Sequential(
    nn.Linear(10, 64),
    nn.GELU(),
    nn.Linear(64, 32),
    nn.GELU(),
    nn.Linear(32, 5),
)

Camadas ocultas em um transformer: GELU. Camadas ocultas em uma CNN: ReLU. Camada de saída para classificação: softmax. Camada de saída para regressão: nenhuma (linear). Camada de saída para probabilidades: sigmoid. É isso. Comece com esses padrões. Mude-os apenas quando tiver evidências.

RNNs e LSTMs usam tanh para o estado oculto e sigmoid para os portões, mas se você está construindo do zero hoje, provavelmente não está usando RNNs. Se os neurônios estão morrendo na sua rede ReLU, troque para GELU. Não recorra à Leaky ReLU a menos que tenha um motivo específico -- a GELU resolve o problema do neurônio morto e dá um melhor fluxo de gradiente.

Entregue

Esta lição produz:

  • outputs/prompt-activation-selector.md -- um prompt reutilizável que ajuda você a escolher a função de ativação certa para qualquer arquitetura

Exercícios

  1. Implemente a Parametric ReLU (PReLU), onde a inclinação negativa alpha é um parâmetro aprendível. Treine-a no conjunto de dados do círculo e compare com a Leaky ReLU fixa.

  2. Rode o experimento do gradiente que desaparece com 50 camadas em vez de 10. Plote a magnitude em cada camada para sigmoid, tanh, ReLU e GELU. Em qual camada o sinal de cada ativação efetivamente chega a zero?

  3. Implemente a ELU (Exponential Linear Unit): elu(x) = x se x > 0, alpha * (e^x - 1) se x <= 0. Compare sua taxa de neurônios mortos com a da ReLU na mesma rede.

  4. Construa um "monitor de saúde de gradiente" que rode durante o treinamento: a cada época, compute a magnitude média do gradiente em cada camada. Imprima um aviso quando o gradiente de qualquer camada cair abaixo de 0,001 ou exceder 100.

  5. Modifique a comparação de treinamento para usar o conjunto de dados XOR da Lição 01 em vez de círculos. Qual ativação converge mais rápido no XOR? Por que isso difere dos resultados do círculo?

Termos-Chave

Termo O que as pessoas dizem O que realmente significa
Função de ativação "A parte não linear" Uma função aplicada à saída de cada neurônio que quebra a linearidade, permitindo que a rede aprenda mapeamentos não lineares
Gradiente que desaparece "Os gradientes somem em redes profundas" Os gradientes encolhem exponencialmente ao longo das camadas quando a derivada da ativação é menor que 1, tornando as primeiras camadas não treináveis
Gradiente que explode "Os gradientes estouram" Os gradientes crescem exponencialmente ao longo das camadas quando o multiplicador efetivo excede 1, causando treinamento instável
Neurônio morto "Um neurônio que parou de aprender" Um neurônio ReLU cuja entrada é permanentemente negativa, produzindo saída zero e gradiente zero
Sigmoid "Comprime valores para 0-1" A função logística 1/(1+e^-x), historicamente importante mas que causa gradientes que desaparecem em redes profundas
ReLU "Recorta negativos para zero" max(0, x) -- a ativação que tornou o deep learning prático ao preservar a magnitude do gradiente
GELU "A ativação do transformer" Gaussian Error Linear Unit, uma ativação suave que pondera as entradas pela probabilidade de serem positivas
Swish/SiLU "ReLU auto-portada" x * sigmoid(x), descoberta por busca automatizada, usada na EfficientNet
Softmax "Transforma pontuações em probabilidades" Normaliza um vetor de logits em uma distribuição de probabilidade onde todos os valores estão em (0,1) e somam 1
Leaky ReLU "ReLU que não morre" max(alpha*x, x) onde alpha é pequeno (0,01), prevenindo neurônios mortos ao permitir pequenos gradientes negativos
Saturação "A parte plana da sigmoid" Regiões onde a derivada de uma ativação se aproxima de zero, bloqueando o fluxo de gradiente
Logit "A pontuação bruta antes da softmax" A saída não normalizada da camada final antes de aplicar softmax ou sigmoid

Leituras Adicionais

  • Nair & Hinton, "Rectified Linear Units Improve Restricted Boltzmann Machines" (2010) -- o artigo que introduziu a ReLU e permitiu o treinamento de redes profundas
  • Hendrycks & Gimpel, "Gaussian Error Linear Units (GELUs)" (2016) -- introduziu a função de ativação que se tornou o padrão para transformers
  • Ramachandran et al., "Searching for Activation Functions" (2017) -- usou busca automatizada para descobrir a Swish, mostrando que o design de ativações pode ser automatizado
  • Glorot & Bengio, "Understanding the difficulty of training deep feedforward neural networks" (2010) -- o artigo que diagnosticou os gradientes que desaparecem/explodem e propôs a inicialização Xavier
  • Goodfellow, Bengio, Courville, "Deep Learning" Capítulo 6.3 (https://www.deeplearningbook.org/) -- tratamento rigoroso de unidades ocultas e funções de ativação
0 lifetime access. Curriculum based on AI Engineering from Scratch by Rohit Ghumare (MIT, used under attribution).