Post

Módulo de Álgebra Linear

Módulo de algebra linear da biblioteca Uraipuru.

Módulo de Álgebra Linear

Quando comecei a escrever minha biblioteca de Machine Learning em C++, a primeira pergunta foi: como vou representar matrizes?

Usar std::vector<std::vector<double>> seria o caminho mais fácil, mas queria entender de verdade o que acontece por baixo dos panos — e ter controle total sobre performance e interface. Então decidi escrever meu próprio módulo de matrizes. Neste post, vou mostrar como ele funciona, as decisões de design que tomei, e por que cada uma delas faz sentido no contexto de ML.


Por que gerenciar memória manualmente?

A primeira decisão foi usar um ponteiro cru (double*) para armazenar os dados, em vez de std::vector.

1
2
3
4
5
6
7
class matriz {
private:
    int linhas;
    int colunas;
    double* dados; // ponteiro para o array flat
    ...
};

A ideia central aqui é guardar todos os elementos em um array unidimensional contíguo na memória, mesmo que a matriz seja bidimensional. Para acessar o elemento da linha i e coluna j, uso a fórmula:

1
dados[i * colunas + j]

Isso se chama row-major order (ordem por linha), e é exatamente como C e C++ organizam arrays multidimensionais nativamente.

Qual é o ganho real disso?

Em ML, você vai multiplicar matrizes milhares de vezes — em cada passo de treinamento de uma rede neural, por exemplo. Com memória contígua, a CPU consegue pré-carregar os dados no cache de forma eficiente, reduzindo drasticamente o número de cache misses. Com vector<vector<double>>, cada linha seria um bloco separado de memória, espalhado pelo heap — muito menos eficiente.


Os construtores: flexibilidade desde o início

O módulo tem três formas de criar uma matriz:

1. Construtor padrão — cria uma matriz vazia:

1
matriz m; // linhas=0, colunas=0, dados=nullptr

2. Construtor por dimensões — aloca e inicializa tudo com zero:

1
matriz m(3, 4); // matriz 3x4 de zeros

3. Construtor com initializer list — ideal para testes e matrizes pequenas:

1
2
3
4
matriz m = {
    {1.0, 2.0, 3.0},
    {4.0, 5.0, 6.0}
};

Esse último usa std::initializer_list<std::initializer_list<double>>, o que permite aquela sintaxe de chaves aninhadas bem legível. Internamente, ele percorre as listas e preenche o array:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
matriz::matriz(std::initializer_list<std::initializer_list<double>> m) {
    this->linhas = m.size();
    this->colunas = m.begin()->size();
    this->dados = new double[linhas * colunas];

    int i = 0;
    for (auto linha : m) {
        int j = 0;
        for (double valor : linha) {
            this->dados[i * this->colunas + j] = valor;
            j++;
        }
        i++;
    }
}

A regra dos três: destrutor, cópia e atribuição

Sempre que você gerencia memória manualmente em C++, precisa implementar a Regra dos Três: destrutor, construtor de cópia e operador de atribuição. Se negligenciar qualquer um deles, você vai ter double free ou memory leak na certa.

Destrutor — libera a memória alocada:

1
2
3
matriz::~matriz() {
    delete[] dados;
}

Construtor de cópia — faz uma cópia profunda (deep copy):

1
2
3
4
5
matriz::matriz(const matriz& outra) : linhas(outra.linhas), colunas(outra.colunas) {
    dados = new double[linhas * colunas];
    for (int i = 0; i < linhas * colunas; i++)
        dados[i] = outra.dados[i];
}

Operador de atribuição — cuida do caso especial a = a e libera a memória antiga antes de copiar:

1
2
3
4
5
6
7
8
9
10
11
12
matriz& matriz::operator=(const matriz& outra) {
    if (this == &outra) return *this; // autoatribuição!

    delete[] dados;
    linhas = outra.linhas;
    colunas = outra.colunas;
    dados = new double[linhas * colunas];
    for (int i = 0; i < linhas * colunas; i++)
        dados[i] = outra.dados[i];

    return *this;
}

Operadores sobrecarregados: escrevendo matemática de verdade

Essa é a parte mais legal do módulo. Com sobrecarga de operadores, o código de ML fica muito mais próximo da notação matemática real.

Soma e subtração

Ambas verificam se as dimensões são compatíveis e operam elemento a elemento:

1
2
matriz c = a + b; // lança std::invalid_argument se dimensões diferirem
matriz d = a - b;

Internamente, é um loop simples sobre o array flat:

1
2
for (int i = 0; i < linhasA * colunasA; i++)
    c.dados[i] = this->dados[i] + b.dados[i];

Multiplicação por escalar

Suporta tanto matriz * 2.0 quanto 2.0 * matriz, graças a uma função friend:

1
2
3
4
5
6
7
// Dentro da classe
matriz operator*(double num) const;

// Fora da classe (friend)
matriz operator*(double num, const matriz& a) {
    return a * num; // delega para o método da classe
}

Uso:

1
2
matriz b = 3.0 * a; // funciona!
matriz c = a * 3.0; // também funciona!

Multiplicação de matrizes

Essa é a operação mais importante para ML — é o coração de qualquer camada de uma rede neural. A implementação segue o algoritmo clássico O(n³):

1
2
3
4
5
6
7
8
9
10
11
12
matriz matriz::operator*(const matriz& b) const {
    if (colunas != b.linhas)
        throw std::invalid_argument("Dimensões incompatíveis para multiplicação");

    matriz c(linhas, b.colunas);
    for (int i = 0; i < linhas; i++)
        for (int j = 0; j < b.colunas; j++)
            for (int k = 0; k < colunas; k++)
                c.dados[i * b.colunas + j] += this->dados[i * colunas + k] * b.dados[k * b.colunas + j];

    return c;
}

Para entender por que colunas == b.linhas é obrigatório: se \(A é m \times n\) e $B$ é $n \times p$, o produto $AB$ é $m \times p$. Se essa condição não for satisfeita, a operação simplesmente não faz sentido matematicamente.

Transposta

Útil em backpropagation e em várias outras operações de ML. A transposta de uma matriz $m \times n$ é uma $n \times m$ onde $A^T[j][i] = A[i][j]$:

1
matriz aT = a.transposta();

Acesso por índice com operator()

Para ler e escrever elementos de forma natural:

1
double& operator()(int i, int j);

Uso:

1
2
m(0, 1) = 42.0;
double x = m(2, 3);

Exemplo de uso completo

Veja como o módulo fica na prática:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include "matriz.h"

int main() {
    // Criar matrizes com initializer list
    matriz pesos = {
        {0.1, 0.2, 0.3},
        {0.4, 0.5, 0.6}
    };

    matriz entrada = {
        {1.0},
        {2.0},
        {3.0}
    };

    // Multiplicação de matrizes (camada linear de uma rede neural)
    matriz saida = pesos * entrada;

    // Escalar o resultado
    matriz saida_escalada = 0.5 * saida;

    // Transposta (útil no backprop)
    matriz pesosT = pesos.transposta();

    // Imprimir
    std::cout << saida << std::endl;

    return 0;
}
This post is licensed under CC BY 4.0 by the author.