← Base: Python e matemática

Aula 5 de 6
  1. 1Python para quem vem de C# e TypeScript
  2. 2NumPy: pensar em arrays, não em laços
  3. 3Gráficos com matplotlib
  4. 4Álgebra linear: vetores, produto escalar e matrizes
  5. 5Cálculo: derivada, derivada parcial e gradiente
  6. 6Probabilidade e estatística: média, variância e distribuição normal

Cálculo: derivada, derivada parcial e gradiente

Objetivo: relembrar a derivada como inclinação, revisar as regras que importam (principalmente a regra da cadeia) e chegar no gradiente, o vetor que diz para onde uma função sobe mais rápido.

Por que isso importa: treinar um modelo é minimizar a função de custo. Pra isso é preciso saber, para cada peso, se aumentá-lo faz o erro subir ou descer, e isso é uma derivada.

  • O gradiente descendente, que treina quase todo modelo de ML, é “dar passinhos contra o gradiente”.
  • O backpropagation, que calcula esse gradiente numa rede neural, é a regra da cadeia aplicada camada por camada.

Esta aula é a matemática de “descer a ladeira”.

import numpy as np
import matplotlib.pyplot as plt

AZUL, LARANJA, VERDE = "#2a78d6", "#eb6834", "#1baf7a"

1. Derivada = inclinação

O problema: você está numa trilha de montanha, com tanta neblina que não enxerga nada, e quer chegar no vale. A única informação que você tem é a inclinação do chão sob os pés. Se o chão desce pra direita, você anda pra direita. Essa inclinação é a derivada.

Como medir a inclinação num ponto xx? Dê um passinho hh pra frente e veja quanto ff mudou. É a velha “subida sobre avanço”:

f′(x)≈f(x+h)−f(x)hf'(x) \approx \frac{f(x + h) - f(x)}{h}

Quanto menor o hh, melhor a aproximação. A derivada “de verdade” é o limite disso quando h→0h \to 0.

Em TypeScript, recebendo a função como parâmetro:

const derivadaAprox = (f: (x: number) => number, x: number, h: number) =>
    (f(x + h) - f(x)) / h;

Em Python é igual: uma função é um valor como outro qualquer e pode ser passada como argumento. No C#, seria um parâmetro do tipo Func<double, double>.

def quadrado(x):
    return x ** 2


def derivada_aprox(f, x, h):
    return (f(x + h) - f(x)) / h      # "subida" dividida pelo "avanço"


# a derivada de x² é 2x; então, em x = 3, a inclinação deve ser 6
for h in [1, 0.1, 0.01, 0.001]:
    print(f"h = {h}: inclinação ≈ {derivada_aprox(quadrado, 3, h):.4f}")
h = 1: inclinação ≈ 7.0000
h = 0.1: inclinação ≈ 6.1000
h = 0.01: inclinação ≈ 6.0100
h = 0.001: inclinação ≈ 6.0010

Com h=1h = 1 o resultado sai 7, com h=0.1h = 0.1 sai 6.1, e ele vai se aproximando de 6. No gráfico: a reta que passa por dois pontos da curva (a secante) vai virando a reta que só encosta na curva (a tangente). A inclinação da tangente é a derivada.

x0 = 1                                  # o ponto onde queremos a inclinação
x = np.linspace(-0.5, 3.5, 200)
tangente = quadrado(x0) + 2 * x0 * (x - x0)     # reta que passa em (1, 1) com inclinação f'(1) = 2

fig, axs = plt.subplots(1, 3, figsize=(13, 4))
for ax, h in zip(axs, [2, 1, 0.3]):
    inclinacao = derivada_aprox(quadrado, x0, h)
    secante = quadrado(x0) + inclinacao * (x - x0)

    ax.plot(x, quadrado(x), color="gray", label="f(x) = x²")
    ax.plot(x, secante, color=AZUL, label="secante")
    ax.plot(x, tangente, color=LARANJA, linestyle="--", label="tangente")
    ax.plot([x0, x0 + h], [quadrado(x0), quadrado(x0 + h)], "o", color=AZUL)   # os dois pontos da secante
    ax.set_ylim(-1, 10)
    ax.set_title(f"h = {h}: inclinação da secante = {inclinacao:.1f}")
    ax.set_xlabel("x")
    ax.grid(alpha=0.3)
axs[0].set_ylabel("f(x)")
axs[0].legend(loc="upper left")
fig.tight_layout()
plt.show()

Gráfico gerado pelo código acima

E se o hh for minúsculo? Também estraga, por causa do float (lembra do 0.1 + 0.2 da aula 00.01?). Um float64 guarda uns 16 dígitos significativos, então 3 + 1e-20 é arredondado de volta pra 3. O number do JS/TS é esse mesmo float64, e lá 3 + 1e-20 === 3 também dá true. (1e-20 é notação científica, 10−2010^{-20}, igual no JS.)

print(3 + 1e-20 == 3)                        # True: a diferença some no arredondamento
print(derivada_aprox(quadrado, 3, 1e-20))    # 0.0 -> "inclinação zero", o que está errado
True
0.0

Na prática, usa-se um hh pequeno, mas não pequeno demais: algo como 1e-5. No exercício 1 você vai escrever uma versão mais precisa dessa conta.

2. As regras que importam

A derivada numérica sempre funciona, mas é aproximada e cara: cada derivada custa duas chamadas de ff, e uma rede neural tem milhões de pesos. As regras de derivação dão a fórmula exata. As que você vai usar:

Função Derivada Exemplo
constante cc 00 f(x)=5⇒f′(x)=0f(x) = 5 \Rightarrow f'(x) = 0
potência xnx^n n xn−1n \, x^{n-1} x3⇒3x2x^3 \Rightarrow 3x^2
número vezes função, c f(x)c \, f(x) c f′(x)c \, f'(x) 4x2⇒8x4x^2 \Rightarrow 8x
soma, f(x)+g(x)f(x) + g(x) f′(x)+g′(x)f'(x) + g'(x) x2+3x⇒2x+3x^2 + 3x \Rightarrow 2x + 3
exponencial exe^x exe^x é a única função que é a própria derivada

Não acredite, confira. A derivada numérica é ótima pra testar uma fórmula feita à mão:

x0 = 1.5
h = 1e-6

# (nome, função, derivada pela regra)
regras = [
    ("x³  ->  3x²",         lambda x: x ** 3,          lambda x: 3 * x ** 2),
    ("4x²  ->  8x",         lambda x: 4 * x ** 2,      lambda x: 8 * x),
    ("x² + 3x  ->  2x + 3", lambda x: x ** 2 + 3 * x,  lambda x: 2 * x + 3),
    ("eˣ  ->  eˣ",          lambda x: np.exp(x),       lambda x: np.exp(x)),
]

for nome, funcao, derivada in regras:
    # {nome:22} ocupa 22 caracteres, pra alinhar as colunas
    print(f"{nome:22} numérica: {derivada_aprox(funcao, x0, h):8.4f}   pela regra: {derivada(x0):8.4f}")
x³  ->  3x²            numérica:   6.7500   pela regra:   6.7500
4x²  ->  8x            numérica:  12.0000   pela regra:  12.0000
x² + 3x  ->  2x + 3    numérica:   6.0000   pela regra:   6.0000
eˣ  ->  eˣ             numérica:   4.4817   pela regra:   4.4817

Regra da cadeia: a mais importante pra ML

O problema: e quando uma função está dentro de outra? Por exemplo, f(x)=(3x+1)2f(x) = (3x + 1)^2. Dá pra expandir o quadrado, mas numa rede neural são dezenas de funções encaixadas, e expandir não é opção.

A regra da cadeia diz: derive a função de fora (mantendo a de dentro intacta) e multiplique pela derivada da de dentro.

Parte Função Derivada
de dentro u=3x+1u = 3x + 1 dudx=3\frac{du}{dx} = 3
de fora f=u2f = u^2 dfdu=2u\frac{df}{du} = 2u
juntando f(x)=(3x+1)2f(x) = (3x + 1)^2 dfdx=2u⋅3=6(3x+1)\frac{df}{dx} = 2u \cdot 3 = 6(3x + 1)

dfdx=dfdu⋅dudx\frac{df}{dx} = \frac{df}{du} \cdot \frac{du}{dx}

Intuição de engenharia: engrenagens. Se a engrenagem uu gira 3 vezes mais rápido que xx, e ff gira 2u2u vezes mais rápido que uu, então ff gira 2u×32u \times 3 vezes mais rápido que xx. As taxas se multiplicam ao longo da cadeia.

Por que isso importa: uma rede neural é uma cadeia: entrada → camada 1 → camada 2 → … → custo. Pra saber como o custo muda quando se mexe num peso lá da camada 1, multiplicam-se as taxas de todas as camadas no caminho. O backpropagation é exatamente isso, feito de trás pra frente.

def encaixada(x):
    return (3 * x + 1) ** 2


def encaixada_linha(x):
    return 2 * (3 * x + 1) * 3      # regra da cadeia: (derivada de fora) × (derivada de dentro)


for x0 in [0, 1, 2]:
    print(f"x = {x0}: numérica {derivada_aprox(encaixada, x0, 1e-6):.4f}   regra da cadeia {encaixada_linha(x0):.4f}")
x = 0: numérica 6.0000   regra da cadeia 6.0000
x = 1: numérica 24.0000   regra da cadeia 24.0000
x = 2: numérica 42.0000   regra da cadeia 42.0000

3. Derivada e mínimo

Lembra do exercício 4 da aula 00.03? Ele desenhou f(x)=(x−2)2f(x) = (x - 2)^2 ao lado da derivada, f′(x)=2(x−2)f'(x) = 2(x - 2). Onde a função tem o ponto mais baixo (x=2x = 2), a derivada vale zero: no fundo do vale, o chão é plano.

E o sinal da derivada diz pra que lado ir:

Onde f′(x)f'(x) O chão… Pra descer, ande pra…
x<2x < 2 negativa desce pra direita direita (xx aumenta)
x=2x = 2 zero é plano lugar nenhum: é o mínimo
x>2x > 2 positiva sobe pra direita esquerda (xx diminui)

Nos dois casos, a regra é a mesma: ande no sentido contrário ao sinal da derivada. Essa é a ideia inteira da próxima seção.

def custo(x):
    return (x - 2) ** 2


def custo_linha(x):
    return 2 * (x - 2)


x = np.linspace(-0.5, 4.5, 200)

fig, ax = plt.subplots(figsize=(7, 4.5))
ax.plot(x, custo(x), color=AZUL, label="f(x) = (x - 2)²")
for i, x0 in enumerate([0.5, 2, 3.5]):
    trecho = np.linspace(x0 - 0.6, x0 + 0.6, 2)
    ax.plot(trecho, custo(x0) + custo_linha(x0) * (trecho - x0), color=LARANJA, linewidth=2.5,
            label="inclinação (tangente)" if i == 0 else None)    # só um item na legenda
    ax.plot(x0, custo(x0), "o", color=LARANJA)
    if x0 < 2:      # rótulo do lado de fora da curva, pra não encostar na tangente
        ax.text(x0 - 0.3, custo(x0) - 0.3, f"f'({x0}) = {custo_linha(x0):.0f}", ha="right", fontsize=11)
    elif x0 > 2:
        ax.text(x0 + 0.3, custo(x0) - 0.3, f"f'({x0}) = {custo_linha(x0):.0f}", ha="left", fontsize=11)
    else:
        ax.text(x0, custo(x0) - 0.9, f"f'({x0}) = {custo_linha(x0):.0f}", ha="center", fontsize=11)
ax.set_ylim(-1.5, 7)
ax.set_title("O sinal da derivada diz pra que lado o chão desce")
ax.set_xlabel("x")
ax.set_ylabel("f(x)")
ax.legend()
ax.grid(alpha=0.3)
plt.show()

Gráfico gerado pelo código acima

4. Gradiente descendente em 1D (só a ideia)

O problema: achar o xx que minimiza ff. Aqui daria pra resolver f′(x)=0f'(x) = 0 na mão, mas com um modelo de milhões de pesos isso é impossível. A saída é começar num ponto qualquer e repetir: dar um passinho contra a derivada.

x←x−taxa×f′(x)x \leftarrow x - \text{taxa} \times f'(x)

Em TypeScript:

let x = -1;
for (let passo = 0; passo < 20; passo++)
    x = x - taxa * custoLinha(x);
  • Por que o menos? Se f′(x)>0f'(x) > 0 (o chão sobe pra direita), xx diminui. Se f′(x)<0f'(x) < 0, xx aumenta. É a tabela da seção 3.
  • A taxa de aprendizado (learning rate) controla o tamanho do passo.
  • Perto do mínimo, a derivada fica pequena, e os passos encolhem sozinhos.
x = -1.0
taxa = 0.1
historico = [x]

for passo in range(20):
    x = x - taxa * custo_linha(x)
    historico.append(x)

print("primeiros passos:", np.round(historico[:6], 3))
print("depois de 20 passos:", round(historico[-1], 4))
primeiros passos: [-1.    -0.4    0.08   0.464  0.771  1.017]
depois de 20 passos: 1.9654

A taxa é o parâmetro mais importante desse algoritmo. Veja três escolhas, 10 passos cada, saindo de x=−1x = -1:

def descer(x, taxa, passos):
    historico = [x]
    for _ in range(passos):          # "_" é o nome convencional pra variável que não vai ser usada
        x = x - taxa * custo_linha(x)
        historico.append(x)
    return np.array(historico)


casos = [(0.05, "pequena: lenta"), (0.3, "boa"), (1.05, "grande demais: diverge")]

fig, axs = plt.subplots(1, 3, figsize=(14, 4))
for ax, (taxa, descricao) in zip(axs, casos):
    caminho = descer(-1.0, taxa, 10)
    limite = max(4.5, np.abs(caminho - 2).max() + 2.5)       # o gráfico da divergência precisa de mais espaço
    x = np.linspace(2 - limite, 2 + limite, 300)

    ax.plot(x, custo(x), color=AZUL, label="f(x)")
    ax.plot(caminho, custo(caminho), color=LARANJA, marker="o", markersize=5, label="passos")
    ax.plot(caminho[0], custo(caminho[0]), "o", color="black", markersize=7, label="início")
    ax.set_title(f"taxa = {taxa} ({descricao})\nx final = {caminho[-1]:.2f}")
    ax.set_xlabel("x")
    ax.grid(alpha=0.3)
axs[0].set_ylabel("f(x)")
axs[0].legend(loc="upper center")
fig.tight_layout()
plt.show()

Gráfico gerado pelo código acima

  • Taxa pequena: anda na direção certa, mas devagar demais. Em 10 passos ainda está longe.
  • Taxa boa: chega perto do mínimo em poucos passos.
  • Taxa grande demais: cada passo passa do mínimo e cai do outro lado, mais alto do que estava. O xx fica pulando de um lado pro outro e se afasta cada vez mais.

🧪 Experimente taxa = 1.0 no descer: o xx fica pulando entre −1 e 5 pra sempre.

A disciplina 01 aprofunda tudo isso: como aplicar em modelos de verdade, como escolher a taxa e quando parar.

5. Derivada parcial

O problema: funções de custo de verdade dependem de vários números. Pegue o terreno do exercício 5 da aula 00.03:

f(x,y)=(x−1)2+2(y+1)2f(x, y) = (x - 1)^2 + 2(y + 1)^2

Qual é a “inclinação” num ponto? Depende da direção em que você anda! Numa montanha, andar pro leste pode ser subida leve, e andar pro norte, uma ribanceira.

A derivada parcial em xx, escrita ∂f∂x\frac{\partial f}{\partial x}, é a inclinação quando você anda só na direção xx. Pra calcular, finja que yy é uma constante e derive normalmente:

Derivada parcial Como pensar Resultado
∂f∂x\frac{\partial f}{\partial x} yy fixo, então 2(y+1)22(y + 1)^2 é constante e sua derivada é 0 2(x−1)2(x - 1)
∂f∂y\frac{\partial f}{\partial y} xx fixo, então (x−1)2(x - 1)^2 é constante 4(y+1)4(y + 1)

Em TypeScript, é a derivada numérica mexendo só um dos argumentos:

const parcialX = (f: (x: number, y: number) => number, x: number, y: number, h: number) =>
    (f(x + h, y) - f(x, y)) / h;     // o y não muda
def terreno(x, y):
    return (x - 1) ** 2 + 2 * (y + 1) ** 2


x0, y0 = 2.5, 0.0
h = 1e-6

parcial_x = (terreno(x0 + h, y0) - terreno(x0, y0)) / h     # só o x anda
parcial_y = (terreno(x0, y0 + h) - terreno(x0, y0)) / h     # só o y anda

print(f"∂f/∂x ≈ {parcial_x:.4f}   (fórmula 2(x - 1) = {2 * (x0 - 1)})")
print(f"∂f/∂y ≈ {parcial_y:.4f}   (fórmula 4(y + 1) = {4 * (y0 + 1)})")
∂f/∂x ≈ 3.0000   (fórmula 2(x - 1) = 3.0)
∂f/∂y ≈ 4.0000   (fórmula 4(y + 1) = 4.0)

Visualmente: fixar y=0y = 0 é como cortar o terreno com uma parede vertical ao longo da linha y=0y = 0. O perfil do corte é uma curva comum de uma variável, g(x)=f(x,0)=(x−1)2+2g(x) = f(x, 0) = (x - 1)^2 + 2, e a inclinação dessa curva é a derivada parcial em xx.

x = np.linspace(-3, 3, 200)
y = np.linspace(-3, 3, 200)
X, Y = np.meshgrid(x, y)

fig, axs = plt.subplots(1, 2, figsize=(11, 4.5))

# esquerda: o terreno visto de cima, com a linha do corte
mapa = axs[0].contourf(X, Y, terreno(X, Y), levels=20, cmap="Blues")
fig.colorbar(mapa, ax=axs[0], label="f(x, y)")
axs[0].axhline(0, color=LARANJA, linewidth=2.5, label="corte em y = 0")
axs[0].plot(x0, y0, "o", color="black")
axs[0].set_title("O terreno visto de cima")
axs[0].set_xlabel("x")
axs[0].set_ylabel("y")
axs[0].set_aspect("equal")
axs[0].legend(loc="upper left")

# direita: o perfil do corte, com a tangente em x = 2.5
corte = terreno(x, 0)
axs[1].plot(x, corte, color=AZUL, label="perfil: f(x, 0)")
trecho = np.linspace(x0 - 1, x0 + 1, 2)
axs[1].plot(trecho, terreno(x0, 0) + parcial_x * (trecho - x0), color=LARANJA, linestyle="--",
            label=f"inclinação ∂f/∂x = {parcial_x:.1f}")
axs[1].plot(x0, terreno(x0, 0), "o", color="black")
axs[1].set_title("O perfil do corte")
axs[1].set_xlabel("x")
axs[1].set_ylabel("f(x, 0)")
axs[1].legend(loc="upper center")
axs[1].grid(alpha=0.3)

fig.tight_layout()
plt.show()

Gráfico gerado pelo código acima

6. Gradiente: a flecha que aponta pra subida

Junte as derivadas parciais num vetor e você tem o gradiente, escrito ∇f\nabla f:

∇f(x,y)=(∂f∂x,  ∂f∂y)\nabla f(x, y) = \left( \frac{\partial f}{\partial x}, \; \frac{\partial f}{\partial y} \right)

No ponto (2.5,0)(2.5, 0) do nosso terreno, ∇f=(3,4)\nabla f = (3, 4).

A propriedade que torna o gradiente tão útil (fica sem demonstração): ele aponta na direção de subida mais íngreme, e o tamanho dele (a norma da aula 00.04) diz o quão íngreme é. Então −∇f-\nabla f aponta pra descida mais íngreme. É daí que vem o nome gradiente descendente.

Pra quem já leu mapa topográfico: a subida mais íngreme cruza as curvas de nível em ângulo reto. As setas abaixo mostram exatamente isso.

def gradiente(x, y):
    return np.array([2 * (x - 1), 4 * (y + 1)])     # (∂f/∂x, ∂f/∂y)


# uma grade grossa de pontos, onde as setas vão nascer
xs = np.linspace(-3, 3, 9)
Xs, Ys = np.meshgrid(xs, xs)
U, V = -gradiente(Xs, Ys)            # componentes das setas: MENOS o gradiente (descida)
tamanho = np.sqrt(U ** 2 + V ** 2)
U, V = U / tamanho * 0.45, V / tamanho * 0.45     # vetor unitário (aula 00.04) × 0.45: só a direção importa

fig, ax = plt.subplots(figsize=(6, 5))
mapa = ax.contourf(X, Y, terreno(X, Y), levels=20, cmap="Blues")
fig.colorbar(mapa, ax=ax, label="f(x, y)")
ax.quiver(Xs, Ys, U, V, angles="xy", scale_units="xy", scale=1, color="#222222")
ax.plot(1, -1, marker="*", markersize=16, color=LARANJA, linestyle="none", label="mínimo")
ax.set_title("Setas: −gradiente (a direção de descida)")
ax.set_xlabel("x")
ax.set_ylabel("y")
ax.set_aspect("equal")
ax.legend(loc="upper left")
plt.show()

Gráfico gerado pelo código acima

Os argumentos do quiver (“aljava”, o estojo de flechas):

  • Xs, Ys: onde cada seta nasce, uma por ponto da grade (o meshgrid da aula 00.03).
  • U, V: as componentes de cada seta, quanto ela anda em xx e quanto anda em yy.
  • angles="xy", scale_units="xy", scale=1: “desenhe as setas nas unidades dos eixos, sem reescalar”. Sem isso, o matplotlib escolhe uma escala por conta própria, e as setas ficam com um tamanho que não tem relação com o gráfico.

Normalizamos as setas pra todas terem o mesmo tamanho, porque aqui só interessa a direção. Repare que perto da estrela elas apontam pra ela, e que todas cruzam as curvas de nível em ângulo reto.

A bolinha descendo até a estrela

Na aula 00.03 ficou a promessa: o gradiente descendente é uma bolinha que desce até a estrela. É a mesma regra do 1D, agora com vetores (soma e escalar da aula 00.04):

ponto←ponto−taxa×∇f(ponto)\text{ponto} \leftarrow \text{ponto} - \text{taxa} \times \nabla f(\text{ponto})

ponto = np.array([-2.5, 2.0])
taxa = 0.1
caminho = [ponto]

for passo in range(30):
    ponto = ponto - taxa * gradiente(ponto[0], ponto[1])    # a mesma regra do 1D, agora com vetores
    caminho.append(ponto)

caminho = np.array(caminho)     # (31, 2): uma linha por posição da bolinha
print("início:", caminho[0], "| fim:", np.round(caminho[-1], 3))

fig, ax = plt.subplots(figsize=(6, 5))
mapa = ax.contourf(X, Y, terreno(X, Y), levels=20, cmap="Blues")
fig.colorbar(mapa, ax=ax, label="f(x, y)")
ax.plot(caminho[:, 0], caminho[:, 1], color=LARANJA, marker="o", markersize=4, label="caminho")
ax.plot(caminho[0, 0], caminho[0, 1], "o", color="black", markersize=8, label="início")
ax.plot(1, -1, marker="*", markersize=16, color="black", linestyle="none", label="mínimo")
ax.set_title("Gradiente descendente em 2D")
ax.set_xlabel("x")
ax.set_ylabel("y")
ax.set_aspect("equal")
ax.legend(loc="upper right")
plt.show()
início: [-2.5  2. ] | fim: [ 0.996 -1.   ]

Gráfico gerado pelo código acima

O caminho faz uma curva: no começo, a bolinha desce mais na direção yy, que é a mais íngreme (lembre do “2” na frente do (y+1)2(y + 1)^2), e depois corrige o xx.

Em ML: xx e yy são os pesos do modelo, e ff é a função de custo. Com um milhão de pesos, o gradiente é um vetor de um milhão de derivadas parciais. Ninguém calcula isso na mão: o PyTorch calcula automaticamente (o autograd da disciplina 02) usando… a regra da cadeia.


Exercícios

Código de apoio dos exercícios
def confere(obtido, esperado):
    if obtido is None:
        print("⏳ ainda não implementado")
        return
    obtido, esperado = np.asarray(obtido), np.asarray(esperado)
    if obtido.shape != esperado.shape:
        print(f"❌ formato esperado {esperado.shape}, mas veio {obtido.shape}")
    elif np.allclose(obtido, esperado):
        print("✅ certo!")
    else:
        print(f"❌ esperado:\n{esperado}\nmas veio:\n{obtido}")

Exercício 1 · Derivada numérica mais precisa

A fórmula da seção 1 olha só pra frente (xx e x+hx + h). Uma versão bem mais precisa olha pros dois lados e divide pela distância total, 2h2h:

f′(x)≈f(x+h)−f(x−h)2hf'(x) \approx \frac{f(x + h) - f(x - h)}{2h}

É a diferença central. Escreva derivada_numerica(f, x, h=1e-5).

def derivada_numerica(f, x, h=1e-5):
    ...  # seu código aqui


confere(derivada_numerica(lambda x: x ** 3, 2), 12)    # 3x² em x = 2
confere(derivada_numerica(np.sin, 0), 1)                # a derivada do seno é o cosseno, e cos(0) = 1
confere(derivada_numerica(np.exp, 1), np.e)             # eˣ é a própria derivada
Dica

É uma linha, igual à derivada_aprox da seção 1, só que com x−hx - h no lugar de xx e 2h2h embaixo.

Gabarito
def derivada_numerica(f, x, h=1e-5):
    return (f(x + h) - f(x - h)) / (2 * h)

Por que é mais precisa? Na versão só pra frente, o erro diminui na proporção de hh. Na central, os erros dos dois lados se cancelam quase todos, e o erro cai na proporção de h2h^2. Com h=10−5h = 10^{-5}, isso é a diferença entre errar na 5ª casa decimal e errar na 10ª.

Exercício 2 · Regra da cadeia na mão

Derive no papel a função

g(x)=(x2+1)3g(x) = (x^2 + 1)^3

usando a regra da cadeia, e escreva a fórmula em g_linha(x). Depois o código confere pra você.

def g(x):
    return (x ** 2 + 1) ** 3


def g_linha(x):
    ...  # seu código aqui


confere(g_linha(0), 0)
confere(g_linha(1), 24)
confere(g_linha(2), 300)
confere(g_linha(-1), -24)

# Bônus: se você fez o exercício 1, compare a sua fórmula com a derivada numérica
if derivada_numerica(g, 1.5) is not None and g_linha(1.5) is not None:
    print(f"em x = 1.5: numérica {derivada_numerica(g, 1.5):.6f} | sua fórmula {g_linha(1.5):.6f}")
Dica

Monte a tabela da seção 2: de dentro, u=x2+1u = x^2 + 1, com derivada 2x2x; de fora, u3u^3, com derivada 3u23u^2. Depois multiplique as duas.

Gabarito

g′(x)=3(x2+1)2⋅2x=6x(x2+1)2g'(x) = 3(x^2 + 1)^2 \cdot 2x = 6x(x^2 + 1)^2

def g_linha(x):
    return 3 * (x ** 2 + 1) ** 2 * (2 * x)    # de fora: 3u²; de dentro: 2x

Exercício 3 · Gradiente numérico

Escreva gradiente_numerico(f, ponto, h=1e-5). A função f recebe um array (o ponto, com quantas coordenadas for) e devolve um número. O resultado deve ser o array das derivadas parciais, uma por coordenada, calculadas pela diferença central do exercício 1.

Pra coordenada ii: mexa só a coordenada ii do ponto, +h+h e −h-h, e mantenha as outras paradas.

def gradiente_numerico(f, ponto, h=1e-5):
    ...  # seu código aqui


# o terreno da seção 5, agora recebendo o ponto como array: em (2.5, 0), o gradiente é (3, 4)
confere(gradiente_numerico(lambda p: (p[0] - 1) ** 2 + 2 * (p[1] + 1) ** 2, np.array([2.5, 0.0])), [3, 4])

# f(x, y) = x·y: ∂f/∂x = y e ∂f/∂y = x
confere(gradiente_numerico(lambda p: p[0] * p[1], np.array([2.0, 3.0])), [3, 2])

# em 3 dimensões: f = x² + y² + z², gradiente = (2x, 2y, 2z)
confere(gradiente_numerico(lambda p: np.sum(p ** 2), np.array([1.0, 2.0, 3.0])), [2, 4, 6])
Dica

np.eye(n) cria a matriz identidade n×nn \times n: a linha ii tem um 1 só na posição ii. Então np.eye(n) * h dá, em cada linha, “um passinho hh só na coordenada ii”. Some e subtraia cada linha do ponto.

Um laço (ou list comprehension) sobre as coordenadas é aceitável aqui.

⚠️ Se você preferir copiar o ponto e mudar uma coordenada, converta antes pra float (np.asarray(ponto, dtype=float)). Num array de inteiros, p[0] += 0.00001 é truncado e não muda nada!

Gabarito
def gradiente_numerico(f, ponto, h=1e-5):
    ponto = np.asarray(ponto, dtype=float)
    passos = np.eye(len(ponto)) * h     # linha i: um passinho h só na coordenada i
    return np.array([(f(ponto + e) - f(ponto - e)) / (2 * h) for e in passos])

Exercício 4 · Gradiente descendente em 1D

Escreva descida_1d(derivada, x_inicial, taxa, passos). Ela aplica passos vezes a regra x←x−taxa×f′(x)x \leftarrow x - \text{taxa} \times f'(x) e devolve o histórico: a lista com todos os valores de xx, incluindo o inicial (então passos + 1 valores).

O último teste acha o mínimo de f(x)=x4−3x3+2f(x) = x^4 - 3x^3 + 2, que não dá pra achar de cabeça. A derivada já vem pronta.

def descida_1d(derivada, x_inicial, taxa, passos):
    ...  # seu código aqui


# f(x) = (x - 2)², a mesma da seção 4
confere(descida_1d(lambda x: 2 * (x - 2), -1.0, 0.1, 3), [-1.0, -0.4, 0.08, 0.464])

# depois de 100 passos, tem que estar no mínimo, x = 2
historico = descida_1d(lambda x: 2 * (x - 2), -1.0, 0.1, 100)
confere(None if historico is None else historico[-1], 2)

# f(x) = x⁴ - 3x³ + 2, com derivada 4x³ - 9x²
historico = descida_1d(lambda x: 4 * x ** 3 - 9 * x ** 2, 4.0, 0.01, 500)
confere(None if historico is None else historico[-1], 2.25)
Dica

É o laço da seção 4 dentro de uma função. Comece a lista com o x_inicial e faça append depois de cada passo.

Gabarito
def descida_1d(derivada, x_inicial, taxa, passos):
    x = x_inicial
    historico = [x]
    for _ in range(passos):
        x = x - taxa * derivada(x)
        historico.append(x)
    return historico

Conferindo o último teste na mão: f′(x)=4x3−9x2=x2(4x−9)f'(x) = 4x^3 - 9x^2 = x^2(4x - 9) vale zero em x=9/4=2.25x = 9/4 = 2.25, e é lá que o algoritmo chegou sozinho.

Exercício 5 (desafio) · O vale estreito

Escreva descida_2d(gradiente, inicio, taxa, passos), a versão 2D do exercício 4. A função gradiente recebe um ponto (array) e devolve o vetor gradiente. O resultado é um array (passos + 1, 2), com uma linha por posição.

O terreno do teste é um vale estreito: f(x,y)=x2+10y2f(x, y) = x^2 + 10y^2. O yy pesa 10 vezes mais que o xx.

def grad_vale(p):
    # gradiente de f(x, y) = x² + 10y²
    return np.array([2 * p[0], 20 * p[1]])


def descida_2d(gradiente, inicio, taxa, passos):
    ...  # seu código aqui


confere(descida_2d(grad_vale, np.array([-4.0, 1.0]), 0.09, 2),
        [[-4.0, 1.0], [-3.28, -0.8], [-2.6896, 0.64]])
# Depois de acertar, rode esta célula pra ver o caminho no vale estreito
caminho = descida_2d(grad_vale, np.array([-4.0, 1.0]), 0.09, 30)

if caminho is not None:
    x = np.linspace(-5, 5, 300)
    y = np.linspace(-1.5, 1.5, 200)
    X, Y = np.meshgrid(x, y)

    fig, ax = plt.subplots(figsize=(10, 3.8))
    mapa = ax.contourf(X, Y, X ** 2 + 10 * Y ** 2, levels=20, cmap="Blues")
    fig.colorbar(mapa, ax=ax, label="f(x, y)", shrink=0.75)    # shrink: barra de cores mais curta, da altura do mapa
    ax.plot(caminho[:, 0], caminho[:, 1], color=LARANJA, marker="o", markersize=4, label="caminho")
    ax.plot(0, 0, marker="*", markersize=16, color="black", linestyle="none", label="mínimo")
    ax.set_title("Gradiente descendente num vale estreito: f(x, y) = x² + 10y²")
    ax.set_xlabel("x")
    ax.set_ylabel("y")
    ax.set_aspect("equal")
    ax.legend(loc="lower right")
    plt.show()

Gráfico gerado pelo código acima

O zigue-zague: num vale estreito, o gradiente aponta pra parede do vale, e não pro mínimo. A taxa precisa ser grande pro xx andar num ritmo razoável, mas aí o yy passa do ponto a cada passo e pula de uma parede pra outra.

🧪 Experimente taxa = 0.11 no gráfico: o yy passa a pular cada vez mais alto e o caminho diverge.

É por isso que existem otimizadores mais espertos, como o momentum e o Adam (disciplina 02). É também um dos motivos pra normalizar os dados (exercício 3 da aula 00.01): com as features na mesma escala, o vale fica mais “redondo” e a descida anda reto.

Dica

É o exercício 4 com vetores: comece com ponto = np.asarray(inicio, dtype=float), guarde cada ponto numa lista e, no fim, converta com np.array(lista).

Gabarito
def descida_2d(gradiente, inicio, taxa, passos):
    ponto = np.asarray(inicio, dtype=float)
    caminho = [ponto]
    for _ in range(passos):
        ponto = ponto - taxa * gradiente(ponto)
        caminho.append(ponto)
    return np.array(caminho)

Resumo

Conceito O que é Em código Em ML
derivada a inclinação da função num ponto (f(x + h) - f(x - h)) / (2 * h) pra que lado mexer um peso
regras potência, constante, soma, exe^x xn⇒nxn−1x^n \Rightarrow n x^{n-1} derivadas exatas e baratas
regra da cadeia função encaixada: as taxas se multiplicam 2 * (3 * x + 1) * 3 backpropagation
mínimo onde a derivada vale zero o melhor modelo
gradiente descendente passos contra a derivada x = x - taxa * derivada(x) treinar o modelo
taxa de aprendizado o tamanho do passo pequena: lenta; grande: diverge o hiperparâmetro mais importante
derivada parcial inclinação numa direção, com as outras variáveis fixas mexer só uma coordenada o efeito de um peso só
gradiente o vetor das derivadas parciais: aponta pra subida mais íngreme np.array([df_dx, df_dy]) o que o PyTorch calcula (autograd)