Introdução a Word Embeddings em C# — série tech

Petrus 
TOTVS Developers

--

Este artigo faz parte de uma série com foco em programação para machine learning e inteligência artificial generativa. Nesta parte introdutória, o objetivo é explicar os conceitos básicos de machine learning e deep learning de forma acessível.

Serão abordados temas como:

  • Aprendizado supervisionado e não supervisionado
  • Redes neurais e backpropagation
  • Estruturas de dados relevantes como vetores, matrizes e grafos
  • Word embeddings e sua construção via co-ocorrência
  • Um exemplo prático de implementação em C#

Ao final deste artigo introdutório, o leitor deverá ter uma compreensão sólida de alguns fundamentos de machine learning e estar preparado para tópicos mais avançados. Os exemplos práticos em C# ajudarão a consolidar o entendimento sobre como aplicar esses conceitos na prática.

Imagina que você quer ensinar um robô a reconhecer animais. Você pode mostrar para ele muitas fotos de cachorros, gatos, cavalos etc e rotulá-las manualmente. Isso seria aprendizado supervisionado, onde você fornece os exemplos e rótulos para o robô.

A partir desses exemplos rotulados, o robô treina seu modelo para detectar padrões nos dados que capturem as características de cada classe, ajustando iterativamente os pesos sinápticos em sua rede neural.”

Dessa forma, o robô consegue inferir as relações implícitas entre dados de entrada e saída desejada por meio do treinamento, sem a necessidade de codificação manual de regras.

Quanto mais exemplos o robô recebe, melhor ele fica em generalizar para novas fotos! É como se ele estivesse desenvolvendo uma intuição do que caracteriza cada animal.

Outro exemplo é fazer o robô aprender a jogar videogame sozinho, apenas vendo a tela e tentando muitas vezes. No começo ele vai errar muito, mas conforme tenta milhares de vezes, ele descobre padrões entre teclas pressionadas e progresso no jogo.

Assim, o robô aprende as relações entre entradas (fotos, telas de jogo) e saídas desejadas (rótulos, pontuação alta) sem precisar codificar regras explicitamente. Esse é o poder do aprendizado de máquina!

Outro exemplo é a análise de sentimentos em avaliações de produtos online. Imagine que você deseja criar um sistema que possa analisar automaticamente os sentimentos expressos nessas avaliações. Isso pode ser extremamente útil para empresas entenderem como os consumidores se sentem em relação aos seus produtos. Em vez de ler manualmente milhares de avaliações, você pode usar o aprendizado de máquina para treinar um modelo que identifique automaticamente se uma avaliação é positiva, negativa ou neutra. O modelo aprenderá a reconhecer padrões nas palavras e frases usadas nas avaliações e associá-los a sentimentos específicos. Isso permitirá que o sistema categorize rapidamente novas avaliações, economizando tempo e fornecendo insights valiosos para as empresas.

Estruturas de dados envolvidas

Algumas estruturas de dados importantes em machine learning e modelos de linguagem:

  • Vetores e matrizes: usados para representar dados numéricos como embeddings de palavras, features de imagens etc.
  • Tensores: generalização de matrices para N dimensões, muito usados em redes neurais. Permitem representar dados como sequências temporais ou volumes 3D.
  • Grafos: usados em redes neurais como RNNs, LSTMs onde os nós são neurônios e arestas são conexões. Também usados em algos como Word2Vec para relações entre palavras.
  • Árvores: usadas em modelos hierárquicos como árvores de decisão ou sintaxe de frases em NLP.
  • Filas e pilhas: usadas para traversing graphs e árvores e em algoritmos de busca.
  • Hash tables (dicionários): permitem busca rápida durante inferência de modelos. Usados para lookups de embeddings por exemplo.
  • Conjuntos: operações como união, interseção e diferença usadas em detecção de anomalias ou análise de dados.
  • Heaps: para extrair máximos/mínimos de forma eficiente em algos como kNN e redes neurais.

Então essas principais estruturas fornecem maneiras eficientes de representar e manipular dados para treinamento e inferência de modelos. A escolha certa da estrutura melhora desempenho e qualidade dos algoritmos.

Vamos começar com código básico Vetores e matrizes: usados para representar dados numéricos como embeddings de palavras.

Algumas vantagens de usar vetores/matrizes:

  • Permite representar palavras por features numéricos densos em vez de one-hot encoding
  • Captura relações semânticas entre palavras pela proximidade dos vetores
  • Matriz de embeddings pode ser aprendida e atualizada durante treinamento
  • Operações matriciais eficientes para calcular similaridade etc

Então isso permite incorporar o significado das palavras nos modelos de ML/NLP!

Gerando Word Embeddings de Forma Simples

Word embeddings são representações vetoriais de palavras que capturam seus significados e relações semânticas. Eles são amplamente utilizados em modelos de processamento de linguagem natural (NLP).

Por exemplo, “cachorro” e “gato” são semanticamente relacionados porque ambos são animais de estimação.

Relações semânticas incluem:

  • Sinonímia — palavras com significados similares (ex: feliz e contente)
  • Antonímia — palavras com significados opostos (ex: alto e baixo)
  • Hiponímia e hiperonímia — relação de especificidade (ex: rosa e flor)
  • Metonímia — relações parte-todo (ex: roda e carro)

Essas relações semânticas não são codificadas diretamente, mas podem ser inferidas pela proximidade dos vetores embeddings treinados em corpus não rotulados.

Eles são geralmente treinados em grandes conjuntos de texto não rotulados, também conhecidos como corpus não rotulados.

Corpus não rotulados consistem em grandes volumes de texto puro, provenientes de livros, enciclopédias, notícias e outras fontes. Esses dados não contêm quaisquer anotações ou categorizações prévias.

Com milhões de sentenças como exemplo, apenas pela proximidade e contexto das palavras, os embeddings conseguem codificar relacionamentos semânticos no espaço vetorial.

Word Embeddings: Conceitos e Teoria

Antes de mergulharmos na implementação prática de word embeddings em C#, é importante entender a teoria por trás dessa técnica e como ela se diferencia das abordagens tradicionais, como o one-hot encoding.

Representação Densa vs. One-Hot Encoding

Uma das características distintas dos word embeddings é a adoção de representações densas em oposição ao one-hot encoding. No one-hot encoding, cada palavra em um vocabulário é representada por um vetor binário de tamanho igual ao tamanho do vocabulário. A palavra em questão é representada por um “1” em sua posição correspondente e “0” em todas as outras posições. No entanto, essa abordagem resulta em vetores esparsos e não captura informações semânticas sobre as palavras.

Os word embeddings, por outro lado, adotam uma abordagem densa, onde cada palavra é representada por um vetor numérico de dimensões reduzidas. Esses vetores são capazes de capturar relações semânticas e contextuais entre palavras, uma vez que posições próximas no espaço vetorial indicam palavras semanticamente similares. Através do treinamento em grandes volumes de texto, os embeddings aprendem a representação dos significados das palavras e suas relações implícitas.

A Intuição por Trás da Co-ocorrência

Uma das bases para a geração de word embeddings é a análise da co-ocorrência de palavras em textos. A ideia é que palavras que ocorrem frequentemente juntas em um contexto possuem uma relação semântica. Esse processo pode ser visto como uma forma de aprendizado não supervisionado, onde os embeddings são construídos através da identificação de padrões de co-ocorrência.

Através da contagem de pares adjacentes de palavras e sua análise, os embeddings capturam as nuances do uso das palavras em diferentes contextos. Esses padrões são então representados em vetores numéricos, permitindo que a similaridade semântica seja calculada através de medidas como a similaridade cosseno.

Vantagens dos Word Embeddings

As vantagens dos word embeddings incluem:

  • Captura de relações semânticas e contextuais entre palavras.
  • Redução da dimensionalidade, permitindo operações mais eficientes.
  • Melhoria na performance de modelos de linguagem e processamento de texto.
  • Habilita a detecção de analogias e relações entre palavras (ex: “rei” — “homem” + “mulher” ≈ “rainha”).

Agora que entendemos a teoria por trás dos word embeddings, vamos explorar a implementação prática em C#.

Mais abaixo:

Vamos por a mão na massa!

Neste artigo, explicaremos passo a passo como gerar embeddings de forma simples em C#.

Pré-processamento do Corpus

Primeiro, carregamos o corpus de texto em uma string:

string text = File.ReadAllText("corpus.txt");

Depois, fazemos pré-processamento básico:

text = text.ToLower();
text = RemoveAcentos(text);

Isso deixa tudo minúsculo e substitui caracteres especiais por espaço.

Inicialização da Matriz

int embeddingDim = 5;  // Dimensão reduzida para simplicidade
double[][] embeddingMatrix = new double[vocab.Count][];
Random rand = new Random();
for (int i = 0; i < vocab.Count; i++)
{
embeddingMatrix[i] = new double[embeddingDim];
for (int j = 0; j < embeddingDim; j++)
{
embeddingMatrix[i][j] = rand.NextDouble();
}
}

Contando Pares de Palavras

Com o texto limpo, podemos contar pares de palavras adjacentes:

Dictionary<(string, string), int> wordPairs = new Dictionary<(string, string), int>();
string[] words = text.Split(' ');
for (int i = 0; i < words.Length - 1; i++)
{
var pair = (words[i], words[i + 1]);
if (wordPairs.ContainsKey(pair))
{
wordPairs[pair]++;
}
else
{
wordPairs[pair] = 1;
}
}

O que sao pares adjacentes ? Porque é relevante conta-los?

Quanto mais duas palavras aparecem juntas, mais relacionadas semanticamente elas tendem a ser.

Pares adjacentes referem-se a pares de palavras que aparecem juntas em uma sequência de texto. Por exemplo, na frase “O gato comeu o rato”, os pares adjacentes seriam: (O, gato), (gato, comeu), (comeu, o), (o, rato).

Relevância de Contar Pares Adjacentes

  1. Co-ocorrência: Contar pares ajuda a entender quão frequentemente duas palavras aparecem juntas. Isso pode indicar uma relação semântica ou sintática entre elas.
  2. Construção de Modelos de Linguagem: Modelos como Bigram e Trigram usam essa contagem para prever a próxima palavra em uma sequência.
  3. Sentido do Contexto: Palavras frequentemente ganham significados específicos com base nas palavras que estão ao redor. Contar pares adjacentes pode ajudar a capturar esse contexto.
  4. Otimização de Pesquisa: Mecanismos de busca e sistemas de recomendação frequentemente utilizam essas estatísticas para melhorar a relevância dos resultados.
  5. Embeddings de Palavras: Ao criar embeddings (como no seu código), a informação sobre pares adjacentes pode ser útil para capturar relações semânticas entre palavras.

Ou seja, a contagem de pares adjacentes oferece insights sobre o contexto e as relações entre palavras, sendo útil para várias aplicações em processamento de linguagem natural.

Construindo a Matriz de Embeddings

Tendo as estatísticas de co-ocorrência, podemos construir a matriz:

foreach (var pair in wordPairs.Keys)
{
int indexWord1 = Array.IndexOf(vocab.ToArray(), pair.Item1);
int indexWord2 = Array.IndexOf(vocab.ToArray(), pair.Item2);
for (int j = 0; j < embeddingDim; j++)
{
embeddingMatrix[indexWord1][j] += embeddingMatrix[indexWord2][j] * wordPairs[pair];
}
}

O algoritmo itera sobre os pares de palavras co-ocorrentes, buscando os índices correspondentes no vocabulário. Os vetores embeddings das palavras de cada par são então somados elemento a elemento, acumulando suas representações vetoriais.

Ou seja, estamos somando os vetores embeddings sempre que duas palavras aparecem juntas no corpus.

Essa soma gradual dos vetores faz com que palavras frequentemente co-ocorrentes tenham embeddings similares, codificando assim a relação semântica entre essas palavras.

Normalização dos Vetores

Depois de construir a matriz de embeddings, fazemos uma normalização:

for (int i = 0; i < vocab.Count; i++)
{
double norm = Math.Sqrt(embeddingMatrix[i].Sum(x => x * x));
for (int j = 0; j < embeddingDim; j++)
{
embeddingMatrix[i][j] /= norm;
}
}

A normalização significa fazer com que todos os vetores (linhas da matriz) tenham o mesmo comprimento (norma). Isso é importante por alguns motivos:

  • Palavras muito frequentes tendem a ter vetores maiores, dominando os outros. A normalização balanceia isso.
  • O comprimento em si não é importante, mas sim a direção do vetor. Normalizando, focamos apenas na direção que contém a informação semântica.
  • Operações como similaridade cosseno ficam mais eficientes com vetores normalizados.
  • Evita problemas numéricos como overflow.

Então a fórmula específica neste caso calcula a “norma L2” de cada vetor (raiz quadrada da soma dos quadrados) e depois divide o vetor por essa norma.

Isso efetivamente redimensiona cada vetor de embeddings para ter norma 1, ao mesmo tempo mantendo a direção/proporção das dimensões que carregam a informação semântica.

Assim, palavras menos frequentes mas semanticamente próximas ainda terão vetores similares. Os vetores codificam relações semânticas de forma mais balanceada.

Exemplo prático

// Aplicação prática: encontrar a palavra mais similar
Console.WriteLine("Digite uma palavra para encontrar a mais similar:");
string targetWord = Console.ReadLine().ToLower();
int targetIndex = Array.IndexOf(vocab.ToArray(), targetWord);

if (targetIndex == -1)
{
Console.WriteLine("Palavra não encontrada no vocabulário.");
return;
}

double maxSimilarity = double.MinValue;
int mostSimilarIndex = -1;

for (int i = 0; i < vocab.Count; i++)
{
if (i == targetIndex) continue;
double similarity = 0;
for (int j = 0; j < embeddingDim; j++)
{
similarity += embeddingMatrix[targetIndex][j] * embeddingMatrix[i][j];
}

if (similarity > maxSimilarity)
{
maxSimilarity = similarity;
mostSimilarIndex = i;
}
}

string mostSimilarWord = vocab.ElementAt(mostSimilarIndex);
Console.WriteLine($"A palavra mais similar a '{targetWord}' é '{mostSimilarWord}' com uma similaridade de {maxSimilarity}.");

Este trecho utiliza a matriz de embeddings treinada para encontrar qual palavra tem o vetor mais similar a uma palavra desejada pelo usuário.

Primeiro pedimos ao usuário para digitar uma palavra. Por exemplo, “casa”.

Depois encontramos o índice desta palavra no vocabulário, para pegarmos seu vetor embeddings:

int targetIndex = vocab.IndexOf(“casa”);

Agora calculamos a similaridade entre este vetor e todos os outros vetores de embeddings, usando a similaridade cosseno:

double similarity = embeddingVector1 * embeddingVector2 / (norm(vec1) * norm(vec2))

O vetor mais similar terá uma pontuação de similaridade (cosine similarity) próxima de 1.

No final, imprimimos a palavra cujo vetor teve maior similaridade com o vetor da palavra desejada pelo usuário.

Exemplo prático:

Usuário digita: casa

Sistema encontra vetor mais similar ao de “casa” e imprime: apartamento

Isso mostra que “casa” e “apartamento” têm vetores próximos, indicando uma relação semântica capturada pelos embeddings.

Espero que este exemplo prático ajude a entender como utilizar os vetores embeddings para busca semântica.

Espero que tenham gostado!

Código completo: Um programa de predição de texto básica.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;

class Program
{
static void Main()
{
// Etapa 1: Pré-processamento e Criação do Vocabulário
string text = "este é um exemplo de texto para demonstrar a construção de uma matriz de embeddings usando pares adjacentes";
//Descomente para aquivos maiores
//string text = File.ReadAllText("corpus.txt");
text = text.ToLower();
text = RemoverAcentos(text);
HashSet<string> vocab = new HashSet<string>(text.Split(' '));// Etapa 2: Inicialização da Matriz de Embeddings
int embeddingDim = 5; // Dimensão reduzida para simplicidade
double[][] embeddingMatrix = new double[vocab.Count][];
Random rand = new Random();
for (int i = 0; i < vocab.Count; i++)
{
embeddingMatrix[i] = new double[embeddingDim];
for (int j = 0; j < embeddingDim; j++)
{
embeddingMatrix[i][j] = rand.NextDouble();
}
}

// Etapa 2.5: Contagem de Pares Adjacentes
Dictionary<(string, string), int> wordPairs = new Dictionary<(string, string), int>();
string[] words = text.Split(' ');
for (int i = 0; i < words.Length - 1; i++)
{
var pair = (words[i], words[i + 1]);
if (wordPairs.ContainsKey(pair))
{
wordPairs[pair]++;
}
else
{
wordPairs[pair] = 1;
}
}

// Etapa 3: Preenchimento da Matriz usando Pares Adjacentes
foreach (var pair in wordPairs.Keys)
{
int indexWord1 = Array.IndexOf(vocab.ToArray(), pair.Item1);
int indexWord2 = Array.IndexOf(vocab.ToArray(), pair.Item2);
for (int j = 0; j < embeddingDim; j++)
{
embeddingMatrix[indexWord1][j] += embeddingMatrix[indexWord2][j] * wordPairs[pair];
}
}

// Etapa 4: Normalização da Matriz
for (int i = 0; i < vocab.Count; i++)
{
double norm = Math.Sqrt(embeddingMatrix[i].Sum(x => x * x));
for (int j = 0; j < embeddingDim; j++)
{
embeddingMatrix[i][j] /= norm;
}
}

// Aplicação prática: encontrar a palavra mais similar
Console.WriteLine("Digite uma palavra para encontrar a mais similar:");
string targetWord = Console.ReadLine().ToLower();
int targetIndex = Array.IndexOf(vocab.ToArray(), targetWord);

if (targetIndex == -1)
{
Console.WriteLine("Palavra não encontrada no vocabulário.");
return;
}

double maxSimilarity = double.MinValue;
int mostSimilarIndex = -1;

for (int i = 0; i < vocab.Count; i++)
{
if (i == targetIndex) continue;
double similarity = 0;
for (int j = 0; j < embeddingDim; j++)
{
similarity += embeddingMatrix[targetIndex][j] * embeddingMatrix[i][j];
}

if (similarity > maxSimilarity)
{
maxSimilarity = similarity;
mostSimilarIndex = i;
}
}

string mostSimilarWord = vocab.ElementAt(mostSimilarIndex);
Console.WriteLine($"A palavra mais similar a '{targetWord}' é '{mostSimilarWord}' com uma similaridade de {maxSimilarity}.");
}
static string RemoverAcentos(string input) //thanks Brito!
{
string normalizedString = input. Normalize(NormalizationForm.FormD);
StringBuilder builder = new StringBuilder();
foreach (char c in normalizedString){
if (CharUnicodeInfo.GetUnicodeCategory(c) != UnicodeCategory.NonSpacingMa
{
builder.Append (c);
}
}
return builder.ToString();
}
}

Para executar este código:

  1. Crie um novo projeto de console em C#.
  2. Insira o código acima no arquivo Program.cs.
  3. Crie um arquivo de texto chamado corpus.txt no mesmo diretório que o executável do programa e preencha-o com o texto que você deseja usar como corpus.
  4. Execute o programa.

O programa tentará ler o arquivo corpus.txt, fará o pré-processamento e a contagem de pares de palavras, e então pedirá que você digite uma frase para prever a próxima palavra com base nos pares contados.

referências:

Diagramas de redes neurais:

Exemplo visual de embeddings:

Arquitetura Word2Vec:

Comparação one-hot vs embedding:

Diagrama de uma RNN:

Visualização do espaço vetorial:

--

--