Phase 03 - Lesson 01

Atenção é o jogo inteiro

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

Você construiu MLPs que misturam features e CNNs que misturam vizinhos. Ambos compartilham uma suposição limitante: eles decidem como combinar as entradas antes mesmo de vê-las. Um kernel de convolução é fixado no momento do treinamento. Ele aplica os mesmos pesos a cada patch de cada imagem. Isso funciona quando a relação útil é local e posicional. Falha quando a relação útil é dependente do conteúdo e pode abranger toda a entrada.

A linguagem é o exemplo canônico. Para resolver o pronome em "o animal não atravessou a rua porque ele estava cansado", você precisa ligar "ele" a "animal", seis tokens atrás. Um kernel de largura fixa não consegue fazer isso para distâncias arbitrárias. Você precisa de uma camada que decida no que prestar atenção com base no conteúdo real, em tempo de execução. Essa camada é a atenção.

De tabelas de lookup ao lookup suave

Comece com algo que você já conhece: um dicionário Python.

d = {"key1": "value1", "key2": "value2"}
result = d["key1"]  # exact match -> "value1"

Um dicionário é um lookup rígido. Sua consulta corresponde exatamente a uma chave, e você recebe de volta exatamente um valor. A atenção é a versão suave e diferenciável dessa mesma operação. Em vez de corresponder a uma chave exatamente, sua consulta é comparada com todas as chaves, produzindo uma pontuação de similaridade para cada uma. Essas pontuações se tornam pesos, e o resultado é uma combinação ponderada de todos os valores.

O vocabulário mapeia diretamente:

  • Query (Q): o que a posição atual está procurando.
  • Key (K): o que cada posição oferece como índice.
  • Value (V): o conteúdo real que cada posição carrega.

Para cada query, você calcula uma similaridade contra cada key, normaliza essas similaridades em pesos que somam um, e toma a soma ponderada dos values. Esse é o mecanismo inteiro. Todo o resto é detalhe.

Atenção por produto escalar escalonado

A similaridade entre uma query e uma key é o produto escalar entre elas. Empilhe todas as queries em uma matriz $Q$, todas as keys em $K$, todos os values em $V$. A saída da atenção é:

$ \text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^\top}{\sqrt{d_k}}\right)V $

Leia da esquerda para a direita. $QK^\top$ produz uma matriz de pontuações de similaridade brutas: a linha $i$, coluna $j$ indica o quanto a query $i$ corresponde à key $j$. Você divide por $\sqrt{d_k}$, a raiz quadrada da dimensão da key, para manter as pontuações em uma faixa razoável. Em seguida, o softmax transforma cada linha em uma distribuição de probabilidade: pesos que são positivos e somam um. Por fim, você multiplica por $V$ para obter, para cada query, a combinação ponderada dos values.

Por que dividir pela raiz quadrada da dimensão da key

Este é o detalhe que todo mundo pula e depois se arrepende. Suponha que os componentes das suas queries e keys sejam variáveis aleatórias independentes com média zero e variância um. O produto escalar entre elas é uma soma de $d_k$ desses produtos. A variância dessa soma cresce linearmente com $d_k$, então o desvio padrão cresce como $\sqrt{d_k}$.

Para uma dimensão de key típica de 64, os produtos escalares brutos têm desvio padrão em torno de 8. Alimente pontuações tão grandes no softmax e ele satura: a maior pontuação domina, a saída se torna quase one-hot, e o gradiente através do softmax colapsa em direção a zero. O treinamento estagna. Dividir por $\sqrt{d_k}$ reescala as pontuações de volta à variância unitária, mantendo o softmax em sua região responsiva onde os gradientes realmente fluem.

import numpy as np

def softmax(x, axis=-1):
    x = x - np.max(x, axis=axis, keepdims=True)
    e = np.exp(x)
    return e / np.sum(e, axis=axis, keepdims=True)

def attention(Q, K, V):
    d_k = Q.shape[-1]
    scores = Q @ K.T / np.sqrt(d_k)
    weights = softmax(scores, axis=-1)
    return weights @ V, weights

Essa é a atenção por produto escalar escalonado em sete linhas. O resto desta lição é sobre entender o que essas sete linhas te dão, e o que elas ainda não têm.

Self-attention versus cross-attention

Quando $Q$, $K$ e $V$ vêm todos da mesma sequência, você tem self-attention: cada posição presta atenção a toda outra posição na mesma sequência, incluindo a si mesma. É assim que um token reúne contexto de seus vizinhos próximos e distantes. É o cavalo de batalha do encoder do transformer.

Quando $Q$ vem de uma sequência e $K$, $V$ vêm de outra, você tem cross-attention. Um decoder de tradução usa cross-attention para permitir que cada posição de saída olhe de volta para toda a frase de entrada. Mesma matemática, fontes diferentes.

Um exemplo trabalhado

Considere três tokens com embeddings bidimensionais. A query para o terceiro token é $[1, 0]$. As keys são $[1, 0]$, $[0, 1]$, $[1, 0]$. Antes do escalonamento, as pontuações brutas são os produtos escalares:

$, $0$,
$. A primeira e a terceira keys correspondem à query; a segunda não. Após o softmax, os pesos se concentram nas posições um e três, e a saída é principalmente uma combinação dos values dessas posições. A query recuperou o conteúdo que estava procurando, por similaridade, não por posição.

Atenção multi-cabeça

Uma única operação de atenção força todas as relações através de uma única função de similaridade. Mas um token pode se relacionar com outros de muitas maneiras ao mesmo tempo: sintaticamente, semanticamente, posicionalmente. A atenção multi-cabeça executa várias operações de atenção em paralelo, cada uma com suas próprias projeções aprendidas de $Q$, $K$, $V$ em um subespaço de menor dimensão.

def multi_head_attention(X, W_q, W_k, W_v, W_o, num_heads):
    seq_len, d_model = X.shape
    d_head = d_model // num_heads
    outputs = []
    for h in range(num_heads):
        Q = X @ W_q[h]
        K = X @ W_k[h]
        V = X @ W_v[h]
        head_out, _ = attention(Q, K, V)
        outputs.append(head_out)
    concatenated = np.concatenate(outputs, axis=-1)
    return concatenated @ W_o

Cada cabeça aprende a se especializar. Em um transformer treinado, uma cabeça pode rastrear a concordância sujeito-verbo enquanto outra resolve pronomes e uma terceira segue a adjacência posicional. Concatenar suas saídas e projetá-las através de $W_o$ combina todas essas visões em uma única representação enriquecida. A contagem total de parâmetros permanece comparável à de uma única cabeça de largura completa, porque cada cabeça trabalha em um subespaço de $d_{\text{model}} / h$.

O que a atenção não consegue fazer sozinha

Olhe novamente para a equação da atenção. Não há nenhuma noção de posição nela. Permute os tokens de entrada e a saída permuta da mesma forma, mas os values são idênticos. A atenção é equivariante a permutações. "Cachorro morde homem" e "homem morde cachorro" produzem o mesmo conjunto de saídas de atenção, apenas reordenadas. Para um modelo de linguagem, isso é catastrófico.

Essa é a lacuna deliberada que esta lição deixa em aberto. A atenção te dá mistura baseada em conteúdo com alcance global, mas é cega à ordem. A correção, o encoding posicional, é o assunto da próxima lição. Guarde esse pensamento: o mecanismo que você acabou de construir é metade do que faz um transformer funcionar. A outra metade é dizer a ele onde as coisas estão.

Prática

Implemente a atenção por produto escalar escalonado a partir da equação, depois verifique três propriedades: cada linha de pesos de atenção soma um, a saída de prestar atenção a keys idênticas é a média dos values, e o escalonamento por $\sqrt{d_k}$ mantém a variância das pontuações próxima de um. O autograder verifica as três.

0 lifetime access. Curriculum based on AI Engineering from Scratch by Rohit Ghumare (MIT, used under attribution).