Vetores

Autor

Douglas Braga

Nota

Esta seção corresponde a Vectors, do capítulo 4 de Grus (2019).

Abstratamente, vetores são objetos que podem ser somados entre si para formar novos vetores, e que podem ser multiplicados por escalares — números — também para formar novos vetores.

Concretamente, para os nossos fins, vetores são pontos em algum espaço de dimensão finita. Você pode não pensar nos seus dados como vetores, mas essa costuma ser uma forma útil de representar dado numérico.

Se você tem altura, peso e idade de muitas pessoas, pode tratar cada pessoa como um vetor de três dimensões, [altura, peso, idade]. Se você dá uma disciplina com quatro provas, pode tratar cada aluno como um vetor de quatro dimensões, [prova1, prova2, prova3, prova4].

A abordagem mais simples, construindo do zero, é representar vetores como listas de números. Uma lista de três números corresponde a um vetor no espaço tridimensional, e vice-versa.

Fazemos isso com um alias de tipo, dizendo que um Vector é apenas uma lista de float:

from typing import List

Vector = List[float]

height_weight_age = [70,   # polegadas
                     170,  # libras
                     40]   # anos

grades = [95,   # prova1
          80,   # prova2
          75,   # prova3
          62]   # prova4

height_weight_age, grades
([70, 170, 40], [95, 80, 75, 62])

Vector = List[float] não cria um tipo novo. É um apelido: em tempo de execução, um Vector é uma lista comum, com todos os métodos de lista e nenhuma restrição adicional.

O que ele muda é a legibilidade. Uma função declarada como def dot(v: Vector, w: Vector) -> float diz o que faz antes de você ler o corpo — e é essa mesma anotação que um verificador de tipos usa para acusar erro sem executar nada.

Este é o primeiro exemplo de um hábito que atravessa o livro inteiro: duas em cada três funções daqui para a frente vêm com os tipos anotados. Vale se acostumar.

Aritmética componente a componente

Vamos precisar fazer aritmética com vetores. Como listas de Python não são vetores — e portanto não oferecem nenhuma facilidade aritmética —, temos que construir essas ferramentas nós mesmos.

Começando pela soma. Vetores somam componente a componente: se dois vetores v e w têm o mesmo comprimento, a soma deles é o vetor cujo primeiro elemento é v[0] + w[0], cujo segundo é v[1] + w[1], e assim por diante. Se não têm o mesmo comprimento, não é permitido somá-los.

Em notação matemática, para \(v = (v_1, \dots, v_n)\) e \(w = (w_1, \dots, w_n)\):

\[v + w = (v_1 + w_1,\; v_2 + w_2,\; \dots,\; v_n + w_n)\]

Somar [1, 2] e [2, 1] resulta em [1 + 2, 2 + 1], ou seja, [3, 3].

Dá para implementar isso facilmente com zip e uma compreensão de lista:

def add(v: Vector, w: Vector) -> Vector:
    """Soma os elementos correspondentes"""
    assert len(v) == len(w), "vectors must be the same length"

    return [v_i + w_i for v_i, w_i in zip(v, w)]

assert add([1, 2, 3], [4, 5, 6]) == [5, 7, 9]

add([1, 2, 3], [4, 5, 6])
[5, 7, 9]
ImportanteO assert como documentação

Repare que a função tem duas linhas de assert com papéis completamente diferentes.

O de dentro, assert len(v) == len(w), é uma verificação de contrato: ele roda toda vez que a função é chamada e falha alto se alguém passar vetores de tamanhos diferentes. Sem ele, o zip truncaria em silêncio no menor dos dois, e você receberia um vetor mais curto sem qualquer aviso — que é bem pior do que um erro.

O de fora, assert add([1, 2, 3], [4, 5, 6]) == [5, 7, 9], é um exemplo executável. Ele mostra o que a função faz melhor do que uma frase, e não pode envelhecer: se alguém quebrar a add, a linha falha na primeira vez que o código roda.

Esse padrão aparece 417 vezes ao longo do livro. Não é enfeite — é como cada função afirma o que faz.

Subtrair funciona do mesmo jeito:

\[v - w = (v_1 - w_1,\; v_2 - w_2,\; \dots,\; v_n - w_n)\]

def subtract(v: Vector, w: Vector) -> Vector:
    """Subtrai os elementos correspondentes"""
    assert len(v) == len(w), "vectors must be the same length"

    return [v_i - w_i for v_i, w_i in zip(v, w)]

assert subtract([5, 7, 9], [4, 5, 6]) == [1, 2, 3]

subtract([5, 7, 9], [4, 5, 6])
[1, 2, 3]

Às vezes vamos querer somar uma lista de vetores componente a componente — criar um novo vetor cujo primeiro elemento é a soma de todos os primeiros elementos, cujo segundo é a soma de todos os segundos, e assim por diante. Para \(m\) vetores \(v^{(1)}, v^{(2)}, \dots, v^{(m)}\), todos de tamanho \(n\), o \(i\)-ésimo elemento do resultado é:

\[\left(v^{(1)} + v^{(2)} + \cdots + v^{(m)}\right)_i \;=\; \sum_{j=1}^{m} v^{(j)}_i\]

def vector_sum(vectors: List[Vector]) -> Vector:
    """Soma todos os elementos correspondentes"""
    # Verifica que vectors não está vazio
    assert vectors, "no vectors provided!"

    # Verifica que os vetores têm todos o mesmo tamanho
    num_elements = len(vectors[0])
    assert all(len(v) == num_elements for v in vectors), "different sizes!"

    # o i-ésimo elemento do resultado é a soma de todo vector[i]
    return [sum(vector[i] for vector in vectors)
            for i in range(num_elements)]

assert vector_sum([[1, 2], [3, 4], [5, 6], [7, 8]]) == [16, 20]

vector_sum([[1, 2], [3, 4], [5, 6], [7, 8]])
[16, 20]

Também vamos precisar multiplicar um vetor por um escalar, o que fazemos simplesmente multiplicando cada elemento por esse número:

\[c \cdot v = (c\,v_1,\; c\,v_2,\; \dots,\; c\,v_n)\]

def scalar_multiply(c: float, v: Vector) -> Vector:
    """Multiplica cada elemento por c"""
    return [c * v_i for v_i in v]

assert scalar_multiply(2, [1, 2, 3]) == [2, 4, 6]

scalar_multiply(2, [1, 2, 3])
[2, 4, 6]

Com essas duas peças, calcular a média componente a componente de uma lista de vetores do mesmo tamanho sai de graça — é a soma de todos, multiplicada por \(1/m\):

\[\bar{v} = \frac{1}{m}\left(v^{(1)} + v^{(2)} + \cdots + v^{(m)}\right)\]

def vector_mean(vectors: List[Vector]) -> Vector:
    """Calcula a média elemento a elemento"""
    n = len(vectors)
    return scalar_multiply(1/n, vector_sum(vectors))

assert vector_mean([[1, 2], [3, 4], [5, 6]]) == [3, 4]

vector_mean([[1, 2], [3, 4], [5, 6]])
[3.0, 4.0]

vector_mean é o centroide de um conjunto de pontos — a posição média deles no espaço.

Guarde essa função. Ela é o coração do algoritmo de k-means: agrupar pontos e recalcular o centro de cada grupo é, literalmente, chamar vector_mean repetidas vezes.

O produto escalar

Uma ferramenta menos óbvia é o produto escalar (dot product). O produto escalar de dois vetores é a soma dos produtos componente a componente:

\[v \cdot w \;=\; \sum_{i=1}^{n} v_i\, w_i \;=\; v_1 w_1 + v_2 w_2 + \cdots + v_n w_n\]

def dot(v: Vector, w: Vector) -> float:
    """Calcula v_1 * w_1 + ... + v_n * w_n"""
    assert len(v) == len(w), "vectors must be same length"

    return sum(v_i * w_i for v_i, w_i in zip(v, w))

assert dot([1, 2, 3], [4, 5, 6]) == 32  # 1 * 4 + 2 * 5 + 3 * 6

dot([1, 2, 3], [4, 5, 6])
32

Duas linhas de código. É a função mais usada do livro inteiro.

Mas o que ela mede? Essa é a parte que importa, e é a que costuma ficar de fora quando se aprende a chamar a função pronta.

Se w tem magnitude 1, o produto escalar dot(v, w) mede o quanto o vetor v se estende na direção de w.

Por exemplo, se w = [1, 0], então dot(v, w) é simplesmente a primeira componente de v. Outra forma de dizer a mesma coisa: é o comprimento do vetor que você obteria projetando v sobre w.

Figura 18.1: O produto escalar como projeção: dot(v, w) mede o quanto v avança na direção de w

Usando o produto escalar, fica fácil calcular a soma dos quadrados de um vetor — que é o produto escalar dele com ele mesmo:

\[v \cdot v \;=\; v_1^2 + v_2^2 + \cdots + v_n^2\]

def sum_of_squares(v: Vector) -> float:
    """Retorna v_1 * v_1 + ... + v_n * v_n"""
    return dot(v, v)

assert sum_of_squares([1, 2, 3]) == 14  # 1 * 1 + 2 * 2 + 3 * 3

sum_of_squares([1, 2, 3])
14

E, com ela, a magnitude (ou comprimento) do vetor — a raiz quadrada da soma dos quadrados:

\[\|v\| \;=\; \sqrt{v \cdot v} \;=\; \sqrt{v_1^2 + v_2^2 + \cdots + v_n^2}\]

import math

def magnitude(v: Vector) -> float:
    """Retorna a magnitude (ou comprimento) de v"""
    return math.sqrt(sum_of_squares(v))   # math.sqrt é a raiz quadrada

assert magnitude([3, 4]) == 5

magnitude([3, 4])
5.0

magnitude([3, 4]) == 5 é o triângulo 3-4-5 do teorema de Pitágoras, que você viu no ensino médio.

Não é coincidência: a magnitude de um vetor é a hipotenusa, generalizada para qualquer número de dimensões. Toda vez que este livro medir “quão longe” ou “quão grande”, vai ser esta conta.

Distância

Agora temos todas as peças para calcular a distância entre dois vetores, definida como:

\[d(v, w) = \sqrt{(v_1 - w_1)^2 + \dots + (v_n - w_n)^2}\]

Em código:

def squared_distance(v: Vector, w: Vector) -> float:
    """Calcula (v_1 - w_1) ** 2 + ... + (v_n - w_n) ** 2"""
    return sum_of_squares(subtract(v, w))

def distance(v: Vector, w: Vector) -> float:
    """Calcula a distância entre v e w"""
    return math.sqrt(squared_distance(v, w))

distance([1, 2, 3], [4, 5, 6])
5.196152422706632

Talvez fique mais claro escrita assim — é a mesma função, na forma que a define:

\[d(v, w) = \|v - w\|\]

def distance(v: Vector, w: Vector) -> float:
    return magnitude(subtract(v, w))

distance([1, 2, 3], [4, 5, 6])
5.196152422706632

Compare as duas versões. A primeira diz como calcular: subtraia, eleve ao quadrado, some, tire a raiz. A segunda diz o que é: a distância entre dois pontos é a magnitude da diferença entre eles.

As duas produzem exatamente o mesmo número. A segunda é preferível não por ser mais curta, mas porque nomeia a ideia — e ideias com nome são as que você reconhece quando reaparecem.

Isso é o bastante para começar. Estas funções vão ser usadas intensamente pelo resto do livro.

Tudo o que você construiu nesta seção existe pronto, mais rápido e mais completo:

import numpy as np

v = np.array([1, 2, 3])
w = np.array([4, 5, 6])

v + w                      # add
v - w                      # subtract
2 * v                      # scalar_multiply
np.mean([v, w], axis=0)    # vector_mean
v @ w                      # dot
np.linalg.norm(v)          # magnitude
np.linalg.norm(v - w)      # distance

A diferença de desempenho não é pequena: o NumPy guarda os números num bloco contíguo de memória e executa as operações em código compilado e vetorizado, enquanto a nossa lista de float é um vetor de ponteiros para objetos Python, percorrido um por um pelo interpretador. Em vetores grandes, a diferença é de ordens de grandeza.

O autor do livro-texto diz isso explicitamente, no meio deste mesmo capítulo: usar listas como vetores é ótimo para exposição e péssimo para desempenho, e em código de produção você deve usar o NumPy. E encerra o capítulo observando que toda essa maquinaria vem de graça com a biblioteca.

Então a regra deste livro — nada de NumPy — não é uma opinião sobre engenharia. É uma escolha sobre ordem de aprendizado. v @ w devolve um número; ele não lhe diz que aquele número mede projeção, nem que ele vira zero quando os vetores são perpendiculares, nem por que ele aparece no meio de uma rede neural. Depois desta seção, você olha v @ w e enxerga a soma dos produtos.

Grus, Joel. 2019. Data Science from Scratch: First Principles with Python. 2nd ed. O’Reilly Media.