← Base: Python e matemática

Aula 4 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

Álgebra linear: vetores, produto escalar e matrizes

Objetivo: relembrar vetores e matrizes pelo lado prático: o que cada operação calcula, como fica em NumPy e onde aparece em ML.

Por que isso importa: álgebra linear é o idioma do ML.

  • Um dado é um vetor.
  • Uma tabela de dados é uma matriz.
  • Um neurônio calcula um produto escalar.
  • Uma camada inteira de rede neural é uma multiplicação de matrizes.

A GPU existe, basicamente, pra fazer essa última conta muito rápido.

import numpy as np
import matplotlib.pyplot as plt

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


# Duas ferramentas pra desenhar vetores. A única novidade é o ax.annotate,
# que desenha uma flecha de um ponto (xytext) até outro (xy).

def preparar_plano(ax, limite=5):
    # deixa o ax com cara de plano cartesiano: eixos no zero, grade e mesma escala em x e y
    ax.axhline(0, color="gray", linewidth=0.8)
    ax.axvline(0, color="gray", linewidth=0.8)
    ax.set_xlim(-limite, limite)
    ax.set_ylim(-limite, limite)
    ax.set_aspect("equal")
    ax.grid(alpha=0.3)


def seta(ax, vetor, cor, nome, origem=(0, 0), rotulo=(0.15, 0.15), no_meio=False):
    # desenha o vetor como uma flecha saindo de "origem", com o nome perto da ponta
    # (rotulo = deslocamento do nome em relação à ponta, ou ao meio da flecha se no_meio=True)
    origem = np.asarray(origem, dtype=float)
    vetor = np.asarray(vetor, dtype=float)
    ponta = origem + vetor
    ax.annotate("", xy=ponta, xytext=origem,
                arrowprops={"arrowstyle": "-|>", "color": cor, "lw": 2, "mutation_scale": 15})
    ancora = origem + vetor / 2 if no_meio else ponta
    ax.text(ancora[0] + rotulo[0], ancora[1] + rotulo[1], nome, color="#222222", fontsize=11)

1. Vetor: uma lista de números (e uma flecha)

O problema: como descrever uma casa pra um modelo? Com uma lista de números, um por característica. Por exemplo, [80, 2, 10] pode ser área, quartos e idade. Isso é um vetor: em C#, um double[]; em NumPy, um array 1D.

Um vetor tem duas leituras, e ML usa as duas:

  • Lista de números: é como os dados ficam guardados.
  • Flecha: com 2 números dá pra desenhar. [3, 2] é uma flecha que sai da origem e vai até o ponto (3, 2). Essa leitura dá a intuição de direção, tamanho e semelhança.

Com 3 números, a flecha vive no espaço 3D. Com 100 números, vive num espaço de 100 dimensões, que ninguém consegue desenhar, mas as contas continuam exatamente as mesmas. Por isso a gente aprende em 2D.

casa = np.array([80, 2, 10])     # área, quartos, idade: um vetor de 3 dimensões
print(casa, casa.shape)

v = np.array([3, 2])             # um vetor de 2 dimensões, que dá pra desenhar

fig, ax = plt.subplots(figsize=(4, 4))
preparar_plano(ax)
seta(ax, v, AZUL, "v = [3, 2]")
plt.show()
[80  2 10] (3,)

Gráfico gerado pelo código acima

2. Soma e multiplicação por um número

Somar vetores é somar posição por posição, exatamente a operação elemento a elemento da aula 00.02: [3, 1] + [1, 2] = [4, 3].

Na leitura de flecha: ponha a cauda de b na ponta de a, e a + b vai da origem até onde você chegou. É como somar deslocamentos: andar 3 pra direita e 1 pra cima, depois mais 1 pra direita e 2 pra cima.

a = np.array([3, 1])
b = np.array([1, 2])
print(a + b)

fig, ax = plt.subplots(figsize=(4, 4))
preparar_plano(ax)
seta(ax, a, AZUL, "a", rotulo=(0.1, -0.5))
seta(ax, b, LARANJA, "b", origem=a, rotulo=(0.25, -0.1), no_meio=True)    # b começando na ponta de a
seta(ax, a + b, VERDE, "a + b", rotulo=(-1.4, 0.2))
plt.show()
[4 3]

Gráfico gerado pelo código acima

Multiplicar por um número (um escalar) multiplica cada posição: 2 * [3, 1] = [6, 2]. A flecha estica (×2), encolhe (×0.5) ou inverte o sentido (×−1), mas continua sobre a mesma reta. Daí vem o nome “escalar”: ele muda a escala do vetor.

fig, ax = plt.subplots(figsize=(4, 4))
preparar_plano(ax)
seta(ax, 1.5 * a, LARANJA, "1.5 a", rotulo=(-0.6, 0.35))
seta(ax, a, AZUL, "a", rotulo=(-0.1, -0.6))
seta(ax, -1 * a, VERDE, "-a", rotulo=(-0.3, 0.3))
plt.show()

Gráfico gerado pelo código acima

3. Tamanho (norma) e distância

O problema: qual o tamanho de um vetor? Na leitura de flecha, é o comprimento, e o comprimento vem do bom e velho Pitágoras. [3, 4] é a hipotenusa de um triângulo retângulo de catetos 3 e 4, então mede 32+42=5\sqrt{3^2 + 4^2} = 5.

Com mais dimensões, a conta é a mesma: a raiz da soma dos quadrados. Esse tamanho se chama norma e se escreve ∥v∥\|v\|:

∥v∥=v12+v22+⋯+vn2\|v\| = \sqrt{v_1^2 + v_2^2 + \dots + v_n^2}

v = np.array([3, 4])
print(np.sqrt(np.sum(v ** 2)))    # a fórmula, escrita à mão
print(np.linalg.norm(v))          # a função pronta (linalg = linear algebra)

print(np.linalg.norm(np.array([1, 2, 2])))   # em 3D: √(1 + 4 + 4) = 3
5.0
5.0
3.0

Distância entre dois pontos é a norma da diferença, ∥a−b∥\|a - b\|. O vetor a - b é a flecha que vai de b até a, e o tamanho dela é a distância. Você já fez essa conta no vizinho mais próximo das aulas 00.01 e 00.02, e ela é a base do k-NN e do k-means.

Vetor unitário: dividir um vetor pela própria norma deixa ele com tamanho 1 sem mudar a direção. Serve quando só a direção importa, como na próxima seção.

a = np.array([4, 3])
b = np.array([1, -1])
print(np.linalg.norm(a - b))       # distância entre os pontos a e b: a - b = [3, 4], então 5

unitario = a / np.linalg.norm(a)
print(unitario, np.linalg.norm(unitario))   # mesma direção, tamanho 1
5.0
[0.8 0.6] 1.0

4. Produto escalar: a conta mais importante do ML

O problema: um modelo simples de preço de casa diz que cada m² vale 3 mil, cada quarto vale 20 mil e cada ano de idade tira 1 mil. Pra casa [80, 2, 10]:

prec¸o=3×80+20×2+(−1)×10=270\text{preço} = 3 \times 80 + 20 \times 2 + (-1) \times 10 = 270

É só multiplicar posição por posição e somar tudo. Em C#:

double preco = 0;
for (int i = 0; i < casa.Length; i++)
    preco += pesos[i] * casa[i];

Essa conta tem nome: produto escalar (dot product), escrito w⋅xw \cdot x:

w⋅x=w1x1+w2x2+⋯+wnxnw \cdot x = w_1 x_1 + w_2 x_2 + \dots + w_n x_n

Em NumPy, é o operador @:

pesos = np.array([3, 20, -1])
casa = np.array([80, 2, 10])

print(np.sum(pesos * casa))   # do jeito da aula 00.02: multiplica elemento a elemento e soma
print(pesos @ casa)           # produto escalar com o operador @
print(np.dot(pesos, casa))    # a mesma coisa, como função
270
270
270

Por que @? No NumPy, o * já é a multiplicação elemento a elemento (aula 00.02). Por isso o Python ganhou, na versão 3.5, um operador só pra multiplicação de vetores e matrizes: o @. Não existe equivalente no C#.

Isso é um neurônio. Um neurônio artificial recebe um vetor de entradas xx, tem um vetor de pesos ww e calcula w⋅x+bw \cdot x + b. Depois vem uma função de ativação, como a ReLU da aula 00.03. Treinar é achar bons valores para os pesos. A regressão linear da disciplina 01 é exatamente esse modelo de preço.

O que o produto escalar mede

Existe uma segunda fórmula que dá o mesmo número:

a⋅b=∥a∥ ∥b∥cos⁡θa \cdot b = \|a\| \, \|b\| \cos\theta

onde θ\theta é o ângulo entre as flechas. Não precisa decorar. O que importa é o sinal, que vem do cosseno:

Ângulo cos⁡θ\cos\theta a⋅ba \cdot b Leitura
menor que 90° positivo positivo as flechas apontam pro mesmo lado
90° 0 zero são perpendiculares: “não têm nada a ver”
maior que 90° negativo negativo apontam pra lados opostos
a = np.array([3, 0])

fig, axs = plt.subplots(1, 3, figsize=(12, 4))
for ax, graus in zip(axs, [30, 90, 150]):
    angulo = np.radians(graus)                                # cos e sin do NumPy trabalham em radianos
    b = 2.5 * np.array([np.cos(angulo), np.sin(angulo)])     # vetor de tamanho 2.5 no ângulo pedido
    preparar_plano(ax, limite=4)
    seta(ax, a, AZUL, "a")
    seta(ax, b, LARANJA, "b")
    ax.set_title(f"{graus}°:  a · b = {a @ b:.1f}")
plt.show()

Gráfico gerado pelo código acima

Similaridade de cosseno

Dividindo o produto escalar pelos dois tamanhos, sobra só o cosseno: um número entre −1 e 1 que diz o quanto dois vetores apontam na mesma direção, ignorando o tamanho.

similaridade(a,b)=a⋅b∥a∥ ∥b∥\text{similaridade}(a, b) = \frac{a \cdot b}{\|a\| \, \|b\|}

É assim que se compara o “significado” de textos e imagens em ML. Cada um vira um vetor, chamado embedding (disciplina 04), e a similaridade de cosseno diz o quanto eles se parecem. Você vai implementar essa função no exercício 2.

5. Matriz × vetor: vários produtos escalares de uma vez

O problema: agora são 4 casas, e queremos o preço previsto de todas com os mesmos pesos. Com as casas numa matriz, uma por linha como na aula 00.02, é um produto escalar por linha. Em C#:

for (int i = 0; i < casas.GetLength(0); i++)       // cada casa (linha)
{
    precos[i] = 0;
    for (int j = 0; j < pesos.Length; j++)         // cada característica (coluna)
        precos[i] += casas[i, j] * pesos[j];
}

Em NumPy, casas @ pesos faz tudo isso:

# colunas: área (m²), quartos, idade (anos)
casas = np.array([
    [ 50, 1, 30],
    [ 80, 2, 10],
    [120, 3,  5],
    [ 65, 2, 20],
])
pesos = np.array([3, 20, -1])

print(casas @ pesos)                                  # um preço por casa
print(np.array([linha @ pesos for linha in casas]))   # a mesma coisa, linha por linha (só pra conferir)
[140 270 415 215]
[140 270 415 215]

A regra dos formatos do @: os números “de dentro” precisam ser iguais, e o resultado fica com os “de fora”.

 casas   @   pesos   =   preços
(4, 3)       (3,)        (4,)
    └─ 3 = 3 ─┘

Cada casa tem 3 números, e o vetor de pesos também precisa ter 3, porque o produto escalar pareia posição com posição. Se os tamanhos não baterem, dá erro:

try:
    casas @ np.array([3, 20])
except ValueError as erro:
    print("Erro:", erro)

# A mensagem é comprida e enigmática, mas o final diz tudo: "size 2 is different from 3".
Erro: matmul: Input operand 1 has a mismatch in its core dimension 0, with gufunc signature (n?,k),(k,m?)->(n?,m?) (size 2 is different from 3)

Cuidado: * e @ são coisas diferentes. casas * pesos também funciona, por broadcasting, mas só multiplica cada coluna pelo seu peso e não soma: o resultado é uma tabela (4, 3). O @ multiplica e soma, e devolve um número por casa.

print(casas * pesos)                   # (4, 3): cada coluna multiplicada pelo seu peso
print((casas * pesos).sum(axis=1))     # somando cada linha, chega no mesmo resultado do @
[[150  20 -30]
 [240  40 -10]
 [360  60  -5]
 [195  40 -20]]
[140 270 415 215]

6. Matriz × matriz: vários modelos de uma vez

O problema: além do preço de venda, queremos prever o aluguel, que usa outros pesos. São dois vetores de pesos, e cada um vira uma coluna de uma matriz W:

             venda   aluguel
área      [[   3,      20 ],
quartos    [  20,     150 ],
idade      [  -1,     -10 ]]

casas @ W calcula o produto escalar de cada casa (uma linha de casas) com cada modelo (uma coluna de W). O resultado tem uma linha por casa e uma coluna por modelo. Em C#, são três for aninhados:

for (int i = 0; i < n; i++)            // cada casa (linha de casas)
    for (int j = 0; j < m; j++)        // cada modelo (coluna de W)
        for (int k = 0; k < p; k++)    // produto escalar entre os dois
            R[i, j] += casas[i, k] * W[k, j];
W = np.array([
    [ 3,  20],
    [20, 150],
    [-1, -10],
])

previsoes = casas @ W
print(previsoes)          # coluna 0: venda (mil R$); coluna 1: aluguel (R$/mês)
print(previsoes.shape)
[[ 140  850]
 [ 270 1800]
 [ 415 2800]
 [ 215 1400]]
(4, 2)

A regra dos formatos é a mesma:

 casas   @    W     =   previsões
(4, 3)      (3, 2)       (4, 2)
    └ 3 = 3 ┘
 os "de fora", 4 e 2, formam o resultado

Isso é uma camada de rede neural. Troque “casas” por “exemplos”, “características” por “entradas” e “modelos” por “neurônios”. X @ W calcula a saída de todos os neurônios da camada, para todos os exemplos, numa operação só. Uma rede neural é uma sequência dessas multiplicações, com funções de ativação entre elas. Você vai montar uma camada no exercício 4.

A ordem importa: A @ B geralmente é diferente de B @ A, e muitas vezes nem existe. W @ casas seria (3, 2) @ (4, 3), e 2 ≠ 4.

Transposta

.T troca linhas por colunas, e um (4, 3) vira (3, 4). É o jeito mais comum de fazer os formatos se encaixarem. Nas fórmulas, você vai ver a transposta escrita como XTX^T.

print(casas.T)
print(casas.shape, "->", casas.T.shape)
[[ 50  80 120  65]
 [  1   2   3   2]
 [ 30  10   5  20]]
(4, 3) -> (3, 4)

7. Bônus: uma matriz transforma o espaço

Até aqui, matriz foi tabela de números. Existe uma terceira leitura, a mais bonita: uma matriz 2×2 é uma função que transforma os pontos do plano. Multiplique a matriz por cada ponto de um desenho, e o desenho inteiro se deforma.

Vamos guardar os pontos de uma casinha como colunas (a linha 0 tem os x e a linha 1 tem os y). Assim, M @ casinha transforma todos os pontos de uma vez: (2, 2) @ (2, n) = (2, n).

casinha = np.array([
    [0, 2, 2, 1, 0, 0],    # x de cada ponto (o último repete o primeiro pra fechar o desenho)
    [0, 0, 2, 3, 2, 0],    # y de cada ponto
])

transformacoes = {
    "esticar x, achatar y": np.array([[1.5, 0], [0, 0.5]]),
    "cisalhamento": np.array([[1, 1], [0, 1]]),
    "espelho": np.array([[-1, 0], [0, 1]]),
}

fig, axs = plt.subplots(1, 3, figsize=(12, 4.5))
for ax, (nome, M) in zip(axs, transformacoes.items()):    # items() devolve pares (chave, valor)
    nova = M @ casinha
    preparar_plano(ax, limite=4)
    ax.plot(casinha[0], casinha[1], color="gray", linestyle="--", label="original")
    ax.plot(nova[0], nova[1], color=AZUL, linewidth=2, label="transformada")
    ax.set_title(f"{nome}\n{M.tolist()}")
    ax.legend(loc="lower left")
plt.show()

Gráfico gerado pelo código acima

O segredo pra ler uma matriz 2×2: a primeira coluna diz para onde vai o ponto (1, 0), e a segunda coluna diz para onde vai o (0, 1). O resto do plano vai junto.

No cisalhamento, [[1, 1], [0, 1]], a primeira coluna é (1, 0), então o (1, 0) fica onde está. A segunda é (1, 1), então o (0, 1) vai para (1, 1). Por isso a casinha “tomba” pro lado.

Por que isso importa em ML: cada camada de uma rede neural transforma o espaço dos dados. Treinar a rede é achar transformações que deixem, por exemplo, as fotos de gato de um lado e as de cachorro do outro.

Pra ir além: a série Essence of linear algebra, do canal 3Blue1Brown no YouTube, mostra essas transformações animadas. É uma revisão visual excelente da matemática da faculdade.


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 · Norma à mão

Escreva norma(v) sem usar np.linalg.norm: a raiz da soma dos quadrados, pra um vetor de qualquer tamanho.

def norma(v):
    ...  # seu código aqui


confere(norma(np.array([3, 4])), 5)
confere(norma(np.array([1, 2, 2])), 3)
confere(norma(np.array([0, 0, 0, 0])), 0)
Dica

Três funções do NumPy resolvem: ** 2, np.sum e np.sqrt. Sem laço!

Gabarito
def norma(v):
    return np.sqrt(np.sum(v ** 2))

Exercício 2 · Similaridade de cosseno

Escreva similaridade_cosseno(a, b), com a fórmula da seção 4:

similaridade(a,b)=a⋅b∥a∥ ∥b∥\text{similaridade}(a, b) = \frac{a \cdot b}{\|a\| \, \|b\|}

Antes de rodar, tente prever cada resultado: flechas perpendiculares, na mesma direção e opostas.

def similaridade_cosseno(a, b):
    ...  # seu código aqui


confere(similaridade_cosseno(np.array([1, 0]), np.array([0, 1])), 0)     # perpendiculares
confere(similaridade_cosseno(np.array([1, 1]), np.array([3, 3])), 1)     # mesma direção, tamanhos diferentes
confere(similaridade_cosseno(np.array([2, 0]), np.array([-5, 0])), -1)   # opostas
Dica

Produto escalar com @ em cima; np.linalg.norm (ou a sua norma) embaixo. Cuidado com os parênteses!

Gabarito
def similaridade_cosseno(a, b):
    return (a @ b) / (np.linalg.norm(a) * np.linalg.norm(b))

Repare no segundo teste: [1, 1] e [3, 3] têm tamanhos diferentes, mas similaridade 1. O cosseno só olha a direção.

Exercício 3 · Um recomendador de filmes

Cada filme é um vetor que diz o quanto ele tem de ação, romance e comédia, de 0 a 1. O gosto de uma pessoa é um vetor no mesmo formato. O produto escalar entre os dois mede a combinação: é alto quando o filme tem muito do que a pessoa gosta.

  1. pontuacoes(filmes, gosto) devolve a pontuação de cada filme, sem laço.
  2. recomendar(filmes, gosto) devolve o índice do filme com a maior pontuação.

É uma versão de brinquedo de como os sistemas de recomendação funcionam: filmes e pessoas viram vetores, e o produto escalar mede a combinação.

nomes = ["Explosão Total", "Amor em Paris", "Rindo à Toa", "Policial Trapalhão", "Casamento Maluco"]

# colunas: ação, romance, comédia
filmes = np.array([
    [0.9, 0.1, 0.2],
    [0.1, 0.9, 0.3],
    [0.2, 0.2, 0.9],
    [0.8, 0.0, 0.6],
    [0.3, 0.7, 0.7],
])
gosto = np.array([1.0, 0.0, 0.5])    # adora ação, não liga pra romance, gosta um pouco de comédia


def pontuacoes(filmes, gosto):
    ...  # seu código aqui


def recomendar(filmes, gosto):
    ...  # seu código aqui


confere(pontuacoes(filmes, gosto), [1.0, 0.25, 0.65, 1.1, 0.65])
confere(recomendar(filmes, gosto), 3)

if recomendar(filmes, gosto) is not None:
    print("Recomendação:", nomes[recomendar(filmes, gosto)])
Dica

A matriz filmes tem um filme por linha, e você quer um produto escalar por linha com o gosto: é a seção 5. Para achar o índice do maior valor, use o argmax da aula 00.02.

Gabarito
def pontuacoes(filmes, gosto):
    return filmes @ gosto

def recomendar(filmes, gosto):
    return pontuacoes(filmes, gosto).argmax()

Repare que o “Policial Trapalhão” (1.1) ganhou do “Explosão Total” (1.0), mesmo tendo menos ação: a comédia que ele tem também conta pontos pra essa pessoa.

Exercício 4 · Uma camada de rede neural

Uma camada de rede neural faz três coisas:

  1. multiplica as entradas pelos pesos: X @ W;
  2. soma um vetor de ajuste b (o bias), um valor por neurônio;
  3. aplica a ReLU, que zera os negativos: np.maximum(0, ...).

saıˊda=ReLU(XW+b)\text{saída} = \text{ReLU}(XW + b)

Escreva camada(X, W, b). Aqui, X tem 2 exemplos com 3 entradas cada, e W tem os pesos de 2 neurônios. Antes de rodar, descubra qual vai ser o formato da saída.

X = np.array([
    [1,  2, 3],
    [0, -1, 1],
])
W = np.array([
    [1, -1],
    [0,  1],
    [1,  0],
])
b = np.array([0, -1])


def camada(X, W, b):
    ...  # seu código aqui


confere(camada(X, W, b), [[4, 0], [1, 0]])
Dica

É uma linha só. X @ W tem formato (2, 2), e + b soma o vetor em cada linha, por broadcasting (aula 00.02).

Gabarito
def camada(X, W, b):
    return np.maximum(0, X @ W + b)

Fazendo à mão: X @ W = [[4, 1], [1, -1]]; somando b = [[4, 0], [1, -2]]; a ReLU zera o −2 e chega em [[4, 0], [1, 0]]. O PyTorch tem isso pronto: nn.Linear faz o X @ W + b e nn.ReLU faz o resto.

Exercício 5 (desafio) · Girando a casinha

A matriz que gira os pontos do plano por um ângulo θ\theta, em volta da origem, é:

R(θ)=[cos⁡θ−sin⁡θsin⁡θcos⁡θ]R(\theta) = \begin{bmatrix} \cos\theta & -\sin\theta \\ \sin\theta & \cos\theta \end{bmatrix}

Escreva rotacionar(pontos, graus), que recebe os pontos como colunas, no formato (2, n) da seção 7, e devolve os pontos girados. Atenção: o ângulo chega em graus.

def rotacionar(pontos, graus):
    ...  # seu código aqui


confere(rotacionar(np.array([[1], [0]]), 90), [[0], [1]])                # (1, 0) girado 90° vira (0, 1)
confere(rotacionar(np.array([[1, 0], [0, 1]]), 180), [[-1, 0], [0, -1]])  # 180° inverte tudo
# Depois de acertar, rode esta célula pra ver a casinha girando
if rotacionar(casinha, 0) is not None:
    fig, ax = plt.subplots(figsize=(5, 5))
    preparar_plano(ax, limite=4)
    for graus, cor in zip([0, 60, 120], [AZUL, LARANJA, VERDE]):
        nova = rotacionar(casinha, graus)
        ax.plot(nova[0], nova[1], color=cor, linewidth=2, label=f"{graus}°")
    ax.set_title("Casinha girando em volta da origem")
    ax.legend()
    plt.show()

Gráfico gerado pelo código acima

Dica

Converta com np.radians(graus), monte a matriz com np.array([[...], [...]]) e multiplique pelos pontos com @.

E por que a fórmula é essa? Lembre do segredo da seção 7: a primeira coluna diz para onde vai o (1, 0). Girar o (1, 0) por θ\theta leva ele para (cos⁡θ,sin⁡θ)(\cos\theta, \sin\theta), que é exatamente a primeira coluna.

Gabarito
def rotacionar(pontos, graus):
    t = np.radians(graus)
    R = np.array([
        [np.cos(t), -np.sin(t)],
        [np.sin(t),  np.cos(t)],
    ])
    return R @ pontos

Resumo

Conceito Conta NumPy Onde aparece em ML
vetor lista de números / flecha np.array([...]) um exemplo (uma casa, uma foto)
soma, escalar posição por posição a + b, 2 * a ajustar os pesos no treino
norma ∑vi2\sqrt{\sum v_i^2} np.linalg.norm(v) o tamanho de um vetor
distância ∥a−b∥\lVert a - b \rVert np.linalg.norm(a - b) k-NN, k-means
produto escalar ∑aibi\sum a_i b_i a @ b um neurônio, regressão linear
similaridade de cosseno a⋅b∥a∥∥b∥\frac{a \cdot b}{\lVert a \rVert \lVert b \rVert} (a @ b) / (norm(a) * norm(b)) comparar embeddings
matriz × vetor um produto escalar por linha X @ w prever para todos os exemplos
matriz × matriz linha × coluna X @ W uma camada de rede neural
transposta linhas ↔ colunas A.T fazer os formatos se encaixarem

A regra do @: (n, k) @ (k, m) → (n, m). Os de dentro precisam ser iguais, e os de fora formam o resultado.