Derivada Is All You Need — Uma aplicação com o algoritmo do gradiente descendente

Anwar Hermuche
14 min readMar 1, 2024

--

Fala, pessoal! Hoje, quero trazer um novo artigo sobre gradiente descendente. Acho que já deu para notar que esse algoritmo é o meu xodó, né?!

Entretanto, hoje quero trazer uma abordagem bem prática. Teremos muito código aqui e espero que você esteja pronto e com alguma IDE por perto.

Vamos começar!

Introdução às Derivadas

Vamos começar explicando o que são derivadas. Para isso, observe o código abaixo, onde eu crio uma parábola.

# Importando as bibliotecas
import numpy as np
import matplotlib.pyplot as plt

# Gráfico da função y = x² - 4x + 8
x = np.arange(-6, 10.5, 0.5)
f = lambda x: x**2 - 4*x + 8

plt.plot(x, f(x))
plt.show()
Imagem 1: do autor

Vamos derivar a função?

Lembre-se de que encontrar a derivada da função em um determinado ponto R significa encontrar o quanto a função tem o seu valor alterado por uma pequena variação no x, ou seja, qual a inclinação da reta tangente ao ponto. E uma reta tangente a um ponto significa que essa reta toca apenas 1 ponto no gráfico, que é o ponto tangente.

Derivando a função para qualquer ponto dela (podemos fazer isso, porque ela é contínua), chegamos na expressão abaixo:

Imagem 2: do autor

Perceba que a inclinação da reta tangente ao ponto x = 6 é igual a 8 (2*6 — 4 = 8). Vamos representar graficamente.

# Calculando o coeficiente angular e linear da reta tangente
# f(6) = 8*6 + b
# 20 = 48 + b
# b = -28
f_linha = lambda x: 8*x - 28

# Reta tangente ao ponto x = 6
x = np.arange(-6, 10.5, 0.5)
f = lambda x: x**2 - 4*x + 8
new_x = np.arange(0, 12, 0.5)

plt.plot(x, f(x))
plt.plot(new_x, f_linha(new_x))
plt.plot(6, 20, marker = 'o', color = 'red')
plt.show()
Imagem 3: do autor

Pelo gráfico, parece que a reta toca outros pontos. Mas, acredite em mim, ela toca apenas o ponto onde x = 6. E por que estou dizendo isso?

Porque se deslizarmos esse ponto vermelho para a esquerda, o coeficiente angular da reta laranja vai se reduzindo cada vez mais. Duvida? Observe os gráficos abaixo.

for k in np.arange(4, 0, -1):
a = 2*k - 4 # coeficiente angular
bias = f(k) - a*k # isso é para calcular aquele b da 3º célula de código
f_linha = lambda x: x*a + bias
new_x = np.arange(k - 6, k + 6, 0.5)

plt.plot(x, f(x))
plt.plot(new_x, f_linha(new_x))
plt.plot(k, f(k), marker = 'o', color = 'red')
plt.title(f"Em x = {k}, a derivada é igual a {a}")
plt.show();
Imagem 4: do autor
Imagem 5: do autor
Imagem 6: do autor
Imagem 7: do autor

Notou que em x = 2 a derivada é 0? Isso significa que no ponto x = 2 a inclinação da reta tangente é igual a 0 (e isso é verdade, visto que a reta é paralela ao eixo x).

Perceba que a derivada ser zero significa que não há variação no ponto específico, podendo ser um mínimo, um máximo ou um ponto de inflexão. Se aumentamos o valor de x, note que o valor da derivada aumenta, indicando que há variação crescente naquele ponto. Se reduzimos o valor de x, note que o valor da derivada reduz, indicando que há uma variação decrescente naquele ponto. Logo, o ponto de derivada zero deve ser algum mínimo, máximo ou um ponto de inflexão.

E todas essas contas servem para entendermos como um algoritmo de Machine Learning funciona internamente para encontrar os melhores parâmetros que reduzem o seu erro.

Entretanto, essas funções utilizadas em Machine Learning possuem mais de uma variável. No exemplo acima tinha apenas uma. Cada coluna de um dataset se tornará uma variável, matematicamente falando. Então, o que fazer nesse cenário?

Funções de 2 variáveis

# Definindo as variáveis
x = 1
y = 1
a = (y - (2*x + 4))**2
print(a)
# a = 25

Note que a variável ‘a’ é uma função de x e y. O que isso quer dizer? Que quando alteramos o valor de ‘x’ ou de ‘y’, alteramos também o valor de ‘a’.

Então, o que acontece se variamos ‘x’ ou ‘y’ bem pouco? Como ‘a’ é afetado? Como estamos interessados na variação em um ponto bem específico da função, temos que derivar ‘a’, mas agora temos duas variáveis e não uma como anteriormente. Então, vamos utilizar um conceito chamado de derivada parcial.

A derivada parcial é utilizada para derivar funções com mais de uma variável, como é o nosso caso agora. A notação da derivada parcial de ‘a’ em relação a ‘x’ é:

Imagem 8: do autor

Vamos realizar as derivadas.

# Definindo x e y
x, y = 1, 1

# Derivando 'a' em relação a 'x'
a_linha_x = 8*x - 4*y + 16 # No ponto x = 1 e y = 1, a_linha_x = 20

# Derivando 'a' em relação a 'y'
a_linha_y = 2*y - 4*x - 8 # No ponto x = 1 e y = 1, a_linha_y = -10

# Verificando a derivada em relação a x com a definição utilizando limites (f(x + h) - f(x))/h, para h tendendo a 0
funcao_a = lambda x, y: (y - (2*x + 4))**2
h = 0.001
derivada = (funcao_a(x + h, y) - funcao_a(x, y))/h
print(f"O valor da derivada de 'a' em relação a 'x' para h = {h} é {derivada}")

# Verificando a derivada em relação a y com a definição utilizando limites (f(x + h) - f(x))/h, para h tendendo a 0
funcao_a = lambda x, y: (y - (2*x + 4))**2
h = 0.001
derivada = (funcao_a(x, y + h) - funcao_a(x, y))/h
print(f"O valor da derivada de 'a' em relação a 'y' para h = {h} é {derivada}")

# O valor da derivada de 'a' em relação a 'x' para h = 0.001 é 20.00399999999658
# O valor da derivada de 'a' em relação a 'y' para h = 0.001 é -9.99899999999343

Note que os valores utilizando a definição formal de limites confirma o que fizemos manualmente. O que, claro, era esperado.

E como encontramos o valor de ‘x’ e ‘y’ que minimizam a função ‘a’?

Lembre-se de que as derivadas de ‘a’ em relação a ‘x’ e ‘y’ foram, respectivamente, 20 e -10, certo? E a = 25 para x = 1 e y = 1. O que acontece se multiplicarmos os resultados da derivada por um número pequeno, tipo 0.001, e subtrairmos dos valores originais de x e y? Vamos testar.

Imagem 9: do autor

Excelente! Com esses novos valores de x e y, vamos recalcular o valor de ‘a’, que resulta em, aproximadamente, 24.5025. Note que o valor reduziu! Vamos repetir o processo? Lembre-se de calcular o valor da derivada nos novos pontos!

Imagem 10: do autor

Calculando o novo valor de ‘a’ para esses novos valores de ‘x’ e ‘y’, temos a = 24.014831643049. Reduzimos ainda mais o resultado da nossa função ‘a’.

Note que se formos fazer dessa forma, passo a passo, um por um, vamos demorar uma eternidade. Então, o que acha de implementarmos um loop em python que execute essa tarefa 10 mil vezes? Vamos ver como os valores de x e y ficam a cada iteração.

x, y = 1, 1
n_iter = 10000
valores_iteracoes = {'x': list(), 'y': list(), 'a': list()}
for iteracao in range(n_iter):
valores_iteracoes['x'].append(x)
valores_iteracoes['y'].append(y)
valor_a = funcao_a(x, y)
valores_iteracoes['a'].append(valor_a)

x = x - 0.001*(8*x - 4*y + 16)
y = y - 0.001*(2*y - 4*x - 8)
# Plotando em um gráfico o valor de 'c' ao longo das iterações
plt.title("Valor de 'a' ao longo das iterações")
plt.plot(range(n_iter), valores_iteracoes['a'])
plt.xlabel('Iteração')
plt.ylabel('a')
plt.show()
Imagem 11: do autor

Perceba que nas primeiras iterações houve uma queda significativa do valor de ‘a’! Depois, foi se reduzindo lentamente, chegando em um valor mínimo de 0 para x = -1.0032051282051175 e y = 1.9935897435897372.

Vamos ver como ‘x’ e ‘y’ variaram ao longo das iterações.

# Criando dois subplots, lado a lado
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))

# Gráfico de 'x'
ax1.plot(range(n_iter), valores_iteracoes['x'])
ax1.set_title("Valor de 'x' ao longo das iterações")
ax1.set_xlabel('Iteração')
ax1.set_ylabel('x')

# Gráfico de 'y'
ax2.plot(range(n_iter), valores_iteracoes['y'])
ax2.set_title("Valor de 'y' ao longo das iterações")
ax2.set_xlabel('Iteração')
ax2.set_ylabel('y')

# Mostrando os gráficos
plt.tight_layout()
plt.show()
Imagem 12: do autor

O que aconteceu é que ‘x’ reduziu ao longo das iterações e ‘y’ aumentou! Por que isso acontece?

Para saber onde teremos um mínimo, temos que derivar a nossa função e igualar a zero. Encontramos que a derivada parcial de ‘a’ em relação a ‘x’ é 8x — 4y + 16 e a derivada parcial a ‘a’ em relação ‘y’ é 2y — 4x — 8. Lembra que, no ponto mínimo, a derivada é igual a zero? Então, vamos igualar essas duas expressões a zero e montar um sistema linear:

Imagem 13: do autor

Resolvendo o sistema linear, chegamos que x = y/2 — 2. Perceba que encontramos os valores mínimos x = -1 e y = 2. Quando substituimos y = 2 na equação, chegamos exatamente em x = -1. Sendo assim, temos infinitos pontos mínimos, todos descritos pela equação da reta x = y/2 — 2

Vamos visualizar a superfície em três dimensões de ‘a’ para cada valor de ‘x’ e ‘y’, a reta dos mínimos e o ponto mínimo em x = -1 e y = 2.

# Importando o plotly
import plotly.graph_objects as go

# Definindo os intervalos para x e y
x_range = np.linspace(-10, 10, 100)
y_range = np.linspace(-10, 10, 100)

# Criando uma malha de pontos
x, y = np.meshgrid(x_range, y_range)

# Calculando os valores de 'a' para cada ponto (x, y)
a_values = (y - (2*x + 4))**2

# Coordenadas do ponto mínimo para y = 2 (conforme a fórmula x = y/2 - 2)
x_min = 2 / 2 - 2
y_min = 2
z_min = (y_min - (2*x_min + 4))**2 # Calculando 'a' para o ponto mínimo

# Criando o gráfico com Plotly
fig = go.Figure(data=[go.Surface(z=a_values, x=x, y=y)])

# Adicionando um marcador no ponto mínimo
fig.add_trace(go.Scatter3d(x=[x_min], y=[y_min], z=[z_min],
mode='markers', marker=dict(size=5, color='red'),
name='Ponto Mínimo'))

# Adicionando uma reta com os pontos mínimos (usando a relação x = y/2 - 2)
y_line = np.linspace(-10, 10, 100)
x_line = y_line / 2 - 2
z_line = (y_line - (2*x_line + 4))**2 # 'a' para a reta dos mínimos

fig.add_trace(go.Scatter3d(x=x_line, y=y_line, z=z_line,
mode='lines', line=dict(color='yellow', width=2),
name='Reta dos Mínimos'))

# Atualizando o layout do gráfico
fig.update_layout(title='Superfície de $a = (y - (2x + 4))^2$ com Ponto Mínimo e Reta dos Mínimos', autosize=False,
width=800, height=600,
margin=dict(l=65, r=50, b=65, t=90))

# Mostrando o gráfico
fig.show()
Imagem 14: do autor

Notou o que acabamos de fazer? Partimos de uma equação e chegamos no ponto mínimo dela utilizando apenas matemática!

Aplicação em Machine Learning

E quando estamos falando de Machine Learning, isso é excelente para encontrarmos os parâmetros ideiais (x e y) de uma função que minimizam uma função de custo. E se você não sabe o que é uma função de custo, basicamente é uma função que mede o quanto estamos errando em um modelo de Machine Learning.

Para ficar fácil visualmente, vou dar um exemplo utilizando a função de custo MSE e uma Regressão Linear. A função de custo MSE é dada por:

Imagem 15: do autor

Aqui, ‘r’ é o nosso valor real. Se estamos trabalhando em um problema prevendo o preço de uma casa, ‘r’ seria o valor real daquela casa. O termo ‘f’ é o valor da nossa feature. Se estamos utilizando o número de metros quadrados para prever o valor da casa, esse número seria a nossa feature. E, novamente, ‘x’ e ‘y’ são os parâmetros que queremos encontrar para minimizar essa função. Pra finalizar, ‘m’ é o número de instâncias (linhas/exemplos do dataset).

Como estamos trabalhando com a função de custo, que mede o quão errada nossas previsões estão, faz muito sentido minimizar! Porque, se minimizarmos, significa que estamos minimizando a função de custo.

Vamos repetir o processo anterior de derivadas partindo de x = 1 e y = 1. Agora, observe abaixo o nosso exemplo da regressão linear prevendo o valor de uma casa a partir do número de metros quadrados.

# Criando os dados
np.random.seed(0)
metros_quadrados = np.arange(40, 320, 0.5)
valor_casa = 4*metros_quadrados + 8 + np.random.randn(len(metros_quadrados))*100

# Visualizando os dados
plt.scatter(metros_quadrados, valor_casa)
plt.title('Relação entre o número de metros quadrados e o valor da casa')
plt.xlabel('Metros Quadrados')
plt.ylabel('Valor da Casa')
plt.show()
Imagem 16: do autor

Viu como nossos dados possuem uma correlação positiva? O nosso objetivo aqui é encontrar uma reta que melhor se encaixe nesses dados! Vou começar com uma reta valor_casa = 1*metros_quadrados + 1.

# Visualizando os dados
plt.scatter(metros_quadrados, valor_casa)
plt.plot(metros_quadrados, metros_quadrados + 1, color = 'red')
plt.title('Relação entre o número de metros quadrados e o valor da casa')
plt.xlabel('Metros Quadrados')
plt.ylabel('Valor da Casa')
plt.show()
Imagem 17: do autor

Note que essa reta está muito mal ajustada! Nós podemos calcular o ‘erro de ajuste’ da reta utilizando o MSE. Aplicando nesta reta, temos o valor abaixo.

# Calculando o MSE
funcao_mse = lambda x, y: np.mean((valor_casa - (x*metros_quadrados + y))**2)
funcao_mse(1, 1)

# 358764.93074618373

O erro foi de 358.764. A unidade desse erro depende da unidade do valor da casa. É em dólares? Reais? Vamos supor que seja em reais. Dessa forma, o erro foi de 358.764 reais². Pois, é: reais ao quadrado. Essa unidade do MSE não permite uma interpretação tão boa, mas nosso foco não é esse agora.

Para encontrar a melhor reta, ou seja, aquela cujos parâmetros ‘x’ e ‘y’ minimizam o nosso MSE, vamos utilizar derivadas!

# Definindo x e y
x, y = 1, 1

# Função para calcular o MSE
funcao_mse = lambda x, y: np.mean((valor_casa - (x * metros_quadrados + y)) ** 2)

# Derivada do 'MSE' em relação a 'x'
mse_linha_x = np.mean(-2 * metros_quadrados * (valor_casa - (x * metros_quadrados + y)))

# Derivada do 'MSE' em relação a 'y'
mse_linha_y = np.mean(-2 * (valor_casa - (x * metros_quadrados + y)))

# Verificando a derivada em relação a x com a definição utilizando limites (f(x + h) - f(x)) / h, para h tendendo a 0
h = 0.001
derivada_x = (funcao_mse(x + h, y) - funcao_mse(x, y)) / h
print(f"O valor da derivada de 'mse' em relação a 'x' para h = {h} é {derivada_x}")

# Verificando a derivada em relação a y com a definição utilizando limites (f(y + h) - f(y)) / h, para h tendendo a 0
derivada_y = (funcao_mse(x, y + h) - funcao_mse(x, y)) / h
print(f"O valor da derivada de 'mse' em relação a 'y' para h = {h} é {derivada_y}")

# O valor da derivada de 'mse' em relação a 'x' para h = 0.001 é -232690.36844617222
# O valor da derivada de 'mse' em relação a 'y' para h = 0.001 é -1083.759343076963

Vamos, agora, fazer aquelas iterações!

x, y = 1, 1  
n_iter = 1000
valores_iteracoes = {'x': list(), 'y': list(), 'mse': list()}
for iteracao in range(n_iter):
valores_iteracoes['x'].append(x)
valores_iteracoes['y'].append(y)
valor_mse = funcao_mse(x, y)
valores_iteracoes['mse'].append(valor_mse)

x = x - 0.00001 * np.mean(-2 * metros_quadrados * (valor_casa - (x * metros_quadrados + y)))
y = y - 0.00001 * np.mean(-2 * (valor_casa - (x * metros_quadrados + y)))

Note que eu reduzi a learning rate, pois o algoritmo estava divergindo. Fiz essa redução porque a escala dos dados é maior e, consequentemente, a learning rate que utilizamos no outro exemplo com dados de menor escala não é suficiente.

Teste o valor anterior de 0.001 e veja o que acontece.

# Plotando em um gráfico o valor de 'c' ao longo das iterações
plt.title("Valor de 'MSE' ao longo das iterações")
plt.plot(range(n_iter), valores_iteracoes['mse'])
plt.xlabel('Iteração')
plt.ylabel('mse')
plt.show()
Imagem 18: do autor

Note que já nas primeiras iterações o algoritmo foi de um custo altíssimo para um custo muuuito menor. Depois disso, foi reduzindo bem aos poucos. Isso significa que ele convergiu para um mínimo!

Vamos analisar, novamente, o comportamento dos parâmetros ao longo das iterações.

# Criando dois subplots, lado a lado
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))

# Gráfico de 'x'
ax1.plot(range(n_iter), valores_iteracoes['x'])
ax1.set_title("Valor de 'x' ao longo das iterações")
ax1.set_xlabel('Iteração')
ax1.set_ylabel('x')

# Gráfico de 'y'
ax2.plot(range(n_iter), valores_iteracoes['y'])
ax2.set_title("Valor de 'y' ao longo das iterações")
ax2.set_xlabel('Iteração')
ax2.set_ylabel('y')

# Mostrando os gráficos
plt.tight_layout()
plt.show()
Imagem 19: do autor

Perceba que X se estabilizou muito próximo do 4 e y muito próximo do 1.07.

Será que tem alguma forma de a gente ver o caminho de forma 3d dos parâmetros, com o resultado do MSE? Tem! Observe o código e o gráfico abaixo:

# Plotando a evolução de x, y e MSE ao longo das iterações
fig = go.Figure()

# Adicionando a trajetória de x e y
fig.add_trace(go.Scatter3d(x=valores_iteracoes['x'], y=valores_iteracoes['y'], z=valores_iteracoes['mse'],
mode='markers+lines', marker=dict(size=5, color='blue'), name='Trajetória de (x, y)'))

# Marcando o ponto inicial
fig.add_trace(go.Scatter3d(x=[valores_iteracoes['x'][0]], y=[valores_iteracoes['y'][0]], z=[valores_iteracoes['mse'][0]],
mode='markers', marker=dict(size=8, color='green'), name='Ponto Inicial'))

# Marcando o ponto final
fig.add_trace(go.Scatter3d(x=[valores_iteracoes['x'][-1]], y=[valores_iteracoes['y'][-1]], z=[valores_iteracoes['mse'][-1]],
mode='markers', marker=dict(size=8, color='red'), name='Ponto Final'))

# Atualizando o layout do gráfico
fig.update_layout(title='Evolução dos Parâmetros x e y com MSE ao longo das Iterações', autosize=False,
width=800, height=600,
scene=dict(
xaxis_title='x',
yaxis_title='y',
zaxis_title='MSE'),
margin=dict(l=65, r=50, b=65, t=90))

# Mostrando o gráfico
fig.show()
Imagem 20: do autor

Note que o valor de X cresceu rapidamente, junto com a redução do MSE. Praticamente nas primeiras 10~20 iterações o algoritmo já havia alcançado um valor bem próximo do valor final para o parâmetro x.

Além disso, o MSE começou perto de 350 mil e se reduziu para 10 mil na milésima iteração! Reduziu bastante. Os parâmetros também foram corretamente ajustados, sendo que x ficou igual a 3.99 e y igual a 1.07.

Vamos ver a superfície e o ponto mínimo?

# Gerando dados hipotéticos para X, Y e MSE
x_range = np.linspace(-10, 10, 560)
y_range = np.linspace(-10, 10, 560)
x, y = np.meshgrid(x_range, y_range)

# Definindo uma função hipotética para o MSE baseada em x e y para visualização
mse_values = (valor_casa - (x * metros_quadrados + y)) ** 2

# Coordenadas do ponto mínimo hipotético da função MSE
x_min, y_min = 3.9, 1.07 # Assumindo o mínimo no centro para ilustração
z_min = np.mean((valor_casa - (x_min * metros_quadrados + y_min)) ** 2)

# Criando o gráfico com Plotly
fig = go.Figure(data=[go.Surface(z=mse_values, x=x, y=y, colorscale='Viridis')])

# Adicionando um marcador no ponto mínimo
fig.add_trace(go.Scatter3d(x=[x_min], y=[y_min], z=[z_min],
mode='markers', marker=dict(size=5, color='red'),
name='Ponto Mínimo'))

# Atualizando o layout do gráfico
fig.update_layout(title='Superfície do MSE com Ponto Mínimo', autosize=False,
scene=dict(
xaxis_title='X',
yaxis_title='Y',
zaxis_title='MSE'),
width=800, height=600,
margin=dict(l=65, r=50, b=65, t=90))

# Mostrando o gráfico
fig.show()
Imagem 21: do autor

Note que chegamos no ponto mínimo dessa função. E como será que nossa reta fica com x = 3.99 e y = 1.07?

# Visualizando os dados
plt.scatter(metros_quadrados, valor_casa)
plt.plot(metros_quadrados, 3.99*metros_quadrados + 1.07, color = 'red')
plt.title('Relação entre o número de metros quadrados e o valor da casa')
plt.xlabel('Metros Quadrados')
plt.ylabel('Valor da Casa')
plt.show()
Imagem 22: do autor

Viu? Se ajustou muito bem aos nossos dados! Esse é o poder da derivada e sua aplicação em Machine Learning.

Conclusão

Pronto, vimos uma boa parte sobre as derivadas e como elas são extremamente úteis no contexto de Machine Learning. Acho muito válido ficar por dentro da teoria matemática por trás dos modelos para não ficar trabalhando como uma caixa preta, isto é, sem saber o que realmente se passa durante o processamento dos dados.

Espero que com esse artigo eu possa ter esclarecido um pouco sobre as derivadas.

Até já, pessoal

--

--