Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Inteligencia Artificial Funcional: Embeddings en Elixir

embedings.livemd

Inteligencia Artificial Funcional: Embeddings en Elixir

Mix.install([
  :req,
  :kino,
  {:nx, "~> 0.6.2"},
  {:vega_lite, "~> 0.1.10"},
   {:kino_vega_lite, "~> 0.1.9"},
  :jason
])

¿Qué son los embeddings?

Un embedding es una representación numérica de un objeto (como una palabra o frase) en un espacio vectorial. Dos objetos con significado similar estarán cerca en ese espacio. Esto permite operar con texto de forma matemática.

📌 Objetivos de esta sección:

Explicar qué son los embeddings.

Justificar su uso en Elixir.

Cargar y preparar embeddings pre-entrenados

🔎 ¿Por qué embeddings?

Los modelos de machine learning no entienden directamente palabras. Necesitamos transformar el lenguaje a números que capturen su significado.
Los embeddings hacen justo eso.

Un ejemplo simple:

  • “gato” y “perro” deberían estar cerca
  • “gato” y “avión” deberían estar lejos

Trabajaremos con Elixir para manipular estos vectores de forma funcional.

Cargando modelos preentrenados

Usaremos embeddings generados previamente (por ejemplo, con BERT, OpenAI o FastText) y los cargaremos en Elixir como un mapa con claves de texto y valores vectoriales.

Podemos descargar un JSON pequeño con ejemplos.

En este ejemplo, usamos un archivo local mini_glove.json que contiene vectores predefinidos para algunas palabras. Esto simula cómo se trabajan los embeddings en tareas reales, pero evitando peticiones a servidores como Hugging Face.

# Usamos un archivo JSON con vectores embebidos (clave: palabra/frase, valor: vector)
# Cargar embeddings desde archivo local
file_path = "Documents/elixir/livebook/glove_demo_large.json"

embeddings =
  File.read!(file_path)
  |> Jason.decode!()

# Ver algunas palabras
#Map.keys(embeddings) |> Enum.take(10)

Distancia semántica entre frases

Creamos una función funcional para medir la similitud entre frases usando la distancia coseno.

defmodule Embeddings do
  def coseno_sim(vec1, vec2) do
    dot = Enum.zip(vec1, vec2) |> Enum.reduce(0.0, fn {a, b}, acc -> acc + a * b end)
    norm1 = :math.sqrt(Enum.reduce(vec1, 0.0, &(&1 * &1 + &2)))
    norm2 = :math.sqrt(Enum.reduce(vec2, 0.0, &(&1 * &1 + &2)))
    dot / (norm1 * norm2)
  end
end

# Ejemplo
Embeddings.coseno_sim(embeddings["apple"], embeddings["red"])

Comparador interactivo

Creamos un formulario donde el usuario puede escribir dos frases y ver qué tan similares son semánticamente.

form = Kino.Control.form([
  frase_1: Kino.Input.text("Frase 1", default: "elixir"),
  frase_2: Kino.Input.text("Frase 2", default: "erlang")
], submit: "Comparar")

Kino.render(form)
frame = Kino.Frame.new()
Kino.render(frame)

Kino.listen(form, fn %{data: %{frase_1: f1, frase_2: f2}} ->
  case {Map.get(embeddings, f1), Map.get(embeddings, f2)} do
    {nil, _} -> 
      Kino.Frame.clear(frame)
      Kino.Frame.append(frame, Kino.Markdown.new("❌ `#{f1}` no tiene embedding cargado"))
    {_, nil} ->
      Kino.Frame.clear(frame)
      Kino.Frame.append(frame, Kino.Markdown.new("❌ `#{f2}` no tiene embedding cargado"))
    {v1, v2} ->
      sim = Embeddings.coseno_sim(v1, v2)
      Kino.Frame.clear(frame)
      Kino.Frame.append(frame, Kino.Markdown.new("""
      ✅ Similaridad coseno entre `#{f1}` y `#{f2}`: **#{Float.round(sim, 4)}**
      """))
  end
end)

Búsqueda semántica

Permite al usuario escribir una frase y obtener las 5 frases más parecidas semánticamente en el dataset.

input = Kino.Input.text("Buscar frases similares a:", default: "elixir")
Kino.render(input)

frame_busqueda = Kino.Frame.new()
Kino.render(frame_busqueda)

Kino.listen(input, fn %{value: frase} ->
  case Map.get(embeddings, frase) do
    nil -> 
      Kino.Frame.clear(frame_busqueda)
      Kino.Frame.append(frame_busqueda, Kino.Markdown.new("❌ No hay embedding para `#{frase}`"))

    vector ->
      similares =
        embeddings
        |> Enum.map(fn {k, v} -> {k, Embeddings.coseno_sim(vector, v)} end)
        |> Enum.reject(fn {k, _} -> k == frase end)
        |> Enum.sort_by(&elem(&1, 1), :desc)
        |> Enum.take(5)

      markdown = similares
      |> Enum.map(fn {k, v} -> "- **#{k}** → #{Float.round(v, 4)}" end)
      |> Enum.join("\n")

      Kino.Frame.clear(frame_busqueda)
      Kino.Frame.append(frame_busqueda, Kino.Markdown.new("""
      ### 🔍 Frases más parecidas a `#{frase}`:
      #{markdown}
      """))
  end
end)

Visualización 2D de los embeddings

Reducimos los vectores a 2 dimensiones con PCA y los graficamos con VegaLite.

# Seleccionamos los 50 primeros
import Nx

frases = embeddings |> Enum.take(50)
vectores = frases |> Enum.map(&elem(&1, 1)) |> Nx.tensor()

# Normalizamos
media = Nx.mean(vectores, axes: [0])
centrado = Nx.subtract(vectores, media)

# PCA: obtenemos componentes principales (simplificado)
{_, _, v} = Nx.LinAlg.svd(centrado)
proyectado = Nx.dot(centrado, v[[.., 0..1]])

# Convertimos a lista de mapas para VegaLite
puntos = frases
|> Enum.with_index()
|> Enum.map(fn {{palabra, _}, i} ->
  [x, y] = proyectado[i] |> Nx.to_flat_list()
  %{palabra: palabra, x: x, y: y}
end)

vl =
  VegaLite.new()
  |> VegaLite.data_from_values(puntos)
  |> VegaLite.mark(:circle)
  |> VegaLite.encode_field(:x, "x", type: :quantitative)
  |> VegaLite.encode_field(:y, "y", type: :quantitative)
  |> VegaLite.encode_field(:tooltip, "palabra", type: :nominal)
  |> then(fn vl ->                # Aumentar tamaño del gráfico
    %{vl | spec: Map.merge(vl.spec, %{"width" => 1000, "height" => 500})}
  end)

Kino.VegaLite.new(vl)

🧠 Conclusión

Con esta notebook hemos logrado:

Cargar y usar embeddings en Elixir de forma funcional.

Medir similitud semántica.

Hacer búsquedas por significado.

Visualizar conceptos similares.

Todo esto sin salir del mundo funcional, usando herramientas de Elixir y Livebook.