# x varia de -50 a 49, y é sempre 20 * x + 5
inputs = [(x, 20 * x + 5) for x in range(-50, 50)]Ajustando Modelos com Gradiente Descendente
Esta seção corresponde a Using Gradient Descent to Fit Models, do capítulo 8 de Grus (2019).
Neste livro, vamos usar gradiente descendente para ajustar modelos parametrizados a dados. No caso usual, temos um conjunto de dados e algum modelo hipotético para os dados, que depende — de forma diferenciável — de um ou mais parâmetros. Também temos uma função de perda (loss) que mede o quanto o modelo erra nos dados — quanto menor, melhor o ajuste.
Se pensarmos nos dados como fixos, nossa função de perda nos diz o quanto um conjunto específico de parâmetros é bom ou ruim. Isso significa que podemos usar gradiente descendente para achar os parâmetros do modelo que deixam a perda a menor possível. Vamos ver um exemplo simples:
Neste caso sabemos os parâmetros da relação linear entre x e y — inclinação 20, intercepto 5 —, mas imagine que quiséssemos aprendê-los a partir dos dados. Vamos usar gradiente descendente para achar a inclinação e o intercepto que minimizam o erro quadrático médio.
O gradiente do erro quadrático
Vamos chamar os parâmetros de theta (θ) — a convenção deste livro, e de quase todo texto de aprendizado de máquina, para “o vetor de parâmetros do modelo, sejam eles quais forem”. Em regressão, esse mesmo vetor costuma se chamar beta, seguindo a notação usual da área. Aqui, theta guarda inclinação e intercepto.
Vamos começar com uma função que determina o gradiente com base no erro de um único ponto de dado:
from scratch.linear_algebra import Vector
def linear_gradient(x: float, y: float, theta: Vector) -> Vector:
slope, intercept = theta
predicted = slope * x + intercept # a previsão do modelo
error = (predicted - y) # o erro é (previsto - real)
squared_error = error ** 2 # vamos minimizar o erro ao quadrado
grad = [2 * error * x, 2 * error] # usando seu gradiente
return gradVale pensar no que esse gradiente significa. Imagine que, para algum x, nossa previsão está grande demais. Nesse caso o error é positivo. O segundo termo do gradiente, 2 * error, também é positivo — o que reflete o fato de que pequenos aumentos no intercepto vão deixar a previsão (já grande demais) ainda maior, o que vai fazer o erro quadrático (para esse x) aumentar mais ainda.
O primeiro termo do gradiente, 2 * error * x, tem o mesmo sinal de x. De fato, se x é positivo, pequenos aumentos na inclinação também vão aumentar a previsão (e, portanto, o erro). Se x é negativo, porém, pequenos aumentos na inclinação vão diminuir a previsão (e, portanto, o erro).
Esse cálculo foi para um único ponto de dado. Para o conjunto de dados inteiro, olhamos para o erro quadrático médio. E o gradiente do erro quadrático médio é simplesmente a média dos gradientes individuais.
Ajustando theta
Então, aqui está o que vamos fazer:
- Começar com um valor aleatório para
theta. - Calcular a média dos gradientes.
- Ajustar
thetanaquela direção. - Repetir.
Esta é a receita que o resto do livro vai repetir, trocando só o que entra em cada seta:
parâmetros aleatórios → gradiente da perda → um passo contra ele → repete
Troque “inclinação e intercepto” por “os pesos de uma rede neural” e “erro quadrático” por “log-verossimilhança”, e é exatamente o que faz o ajuste de uma regressão logística ou de uma rede neural. O laço não muda; só o que ele recebe.
gradient_step já foi escrita na seção 5.3, e vector_mean vem do Capítulo 4 — a média componente a componente de uma lista de vetores:
import random
from scratch.gradient_descent import gradient_step
from scratch.linear_algebra import vector_meanDepois de muitos epochs, deveríamos aprender algo perto dos parâmetros corretos:
random.seed(0)
# Começa com valores aleatórios para inclinação e intercepto
theta = [random.uniform(-1, 1), random.uniform(-1, 1)]
learning_rate = 0.001
def mse(theta: Vector, dados) -> float:
slope, intercept = theta
return sum((slope * x + intercept - y) ** 2 for x, y in dados) / len(dados)
erros = [mse(theta, inputs)]
for epoch in range(5000):
# Calcula a média dos gradientes
grad = vector_mean([linear_gradient(x, y, theta) for x, y in inputs])
# Dá um passo naquela direção
theta = gradient_step(theta, grad, -learning_rate)
erros.append(mse(theta, inputs))
# o erro quadrático médio nunca deveria SUBIR de um epoch para o outro
assert all(depois <= antes for antes, depois in zip(erros, erros[1:]))
erros[0], erros[-1](310762.96548521525, 4.106657588388331e-08)
slope, intercept = theta
assert 19.9 < slope < 20.1, "a inclinação deveria ser aproximadamente 20"
assert 4.9 < intercept < 5.1, "o intercepto deveria ser aproximadamente 5"
slope, intercept(19.99999987827074, 4.999797320703872)
O erro quadrático médio cai de mais de 3 × 10⁵ para menos de 10⁻⁷ ao longo de 5.000 epochs — e o assert acima confirma, epoch a epoch, que ele nunca sobe. Isso não é porque cada passo garante uma perda menor: essa garantia só vale para um passo infinitesimal, e o nosso é finito. O que garante o decréscimo aqui é a mesma lição da seção 5.4, agora do lado seguro — learning_rate = 0.001 é pequeno o bastante frente à curvatura desta perda para que cada passo finito ainda ande na direção certa, em vez de supercorrigir e passar do ponto.
E há uma segunda razão para a busca nunca “se perder”: a seção 5.1 avisou que gradiente descendente pode encontrar um mínimo local errado, dependendo de onde a busca começa. Aqui isso não é risco — o erro quadrático médio de um modelo linear é uma função convexa de theta, uma tigela de verdade, sem vales escondidos —, então não importa o ponto de partida aleatório: a busca sempre chega ao mesmo lugar. É por isso que a seção 5.6, adiante, pode trocar a semente e a forma de percorrer os dados sem trocar o destino.
A inclinação e o intercepto encontrados ficam a menos de 0.001 dos valores reais, 20 e 5.
Essa mesma conta — inclinação e intercepto que minimizam o erro quadrático — tem também um caminho bem diferente. A regressão linear simples ajusta a mesma coisa com uma fórmula fechada: duas médias, uma covariância, uma variância, sem laço nenhum.
Aqui chegamos a esse tipo de resposta por outro caminho — iterando, um pequeno passo de cada vez. Para regressão linear simples, a fórmula fechada é mais rápida e mais exata, e por isso ela existe. Mas ela só existe porque o problema é simples o bastante para ter solução fechada; é por isso que a regressão linear simples pode se dar ao luxo de usá-la. A regressão logística e as redes neurais não têm fórmula fechada nenhuma — para elas, o caminho que você acabou de construir aqui é o único caminho.
scikit-learn
from sklearn.linear_model import LinearRegression, SGDRegressor
X = [[x] for x, y in inputs]
y = [y for x, y in inputs]
# fórmula fechada (mínimos quadrados via álgebra linear)
modelo_fechado = LinearRegression().fit(X, y)
modelo_fechado.coef_, modelo_fechado.intercept_
# gradiente descendente estocástico, a mesma ideia da seção 5.6
modelo_sgd = SGDRegressor(max_iter=1000).fit(X, y)
modelo_sgd.coef_LinearRegression não usa gradiente descendente — ela resolve o problema por álgebra linear direta (decomposição em valores singulares), o que é possível porque a regressão linear tem solução fechada. SGDRegressor é o que se aproxima do que construímos aqui: ela ajusta os parâmetros passo a passo, e é a opção que o scikit-learn recomenda quando o conjunto de dados é grande demais para caber em memória de uma vez, ou quando o modelo — diferente da regressão linear — não tem fórmula fechada. É exatamente esse segundo caso que justifica todo o resto deste capítulo.