48. Proyecto integrador de álgebra lineal aplicada

Construiremos un recomendador de recursos educativos que represente intereses y contenidos mediante vectores, calcule similitudes, ordene resultados y aprenda de la interacción del usuario.

48.1 Objetivo del proyecto

El sistema recibirá un perfil de intereses y devolverá los recursos del catálogo que mejor coincidan con él. Cada recurso y cada usuario ocuparán un punto en el mismo espacio vectorial.

perfil del usuario + matriz del catálogo ⟶ puntuaciones ⟶ recomendaciones

El proyecto integra vectores, matrices, norma, producto escalar, normalización, transformaciones, ranking y evaluación.

48.2 Definición del problema

Utilizaremos cuatro características: álgebra, programación, gráficos y datos. Cada valor estará entre 0 y 5, donde 0 significa ausencia y 5, presencia o interés muy alto.

EntradaRepresentación
Perfil del usuarioVector de 4 componentes
Catálogo de n recursosMatriz n × 4
PuntuacionesVector de n componentes
ResultadoLista ordenada de recursos

Todos los vectores deben respetar exactamente el mismo orden de características.

48.3 Diccionario de características

Conviene definir una única fuente de verdad para los nombres y sus posiciones. Así evitamos comparar componentes con significados distintos.

const caracteristicas = ["álgebra", "programación", "gráficos", "datos"];
const indice = Object.fromEntries(
  caracteristicas.map((nombre, posicion) => [nombre, posicion])
);

console.log(indice.datos);       // 3
console.log(caracteristicas[2]); // gráficos

48.4 Construcción del catálogo

Cada fila de la matriz C describe un recurso. Los nombres se guardan en un arreglo paralelo con la misma cantidad y el mismo orden de filas.

const nombres = [
  "Matrices desde cero",
  "Gráficos 3D con JavaScript",
  "Análisis de datos",
  "Inteligencia artificial práctica"
];

const C = [
  [5, 2, 0, 1],
  [3, 5, 5, 1],
  [3, 4, 0, 5],
  [4, 5, 2, 5]
];

console.log(nombres.length === C.length); // true

48.5 Perfil del usuario

El perfil u utiliza las mismas cuatro coordenadas. Puede obtenerse mediante preferencias declaradas o estimarse a partir del historial.

u = [interés en álgebra, programación, gráficos, datos]
const usuario = {
  nombre: "Ana",
  perfil: [4, 5, 1, 5]
};

console.log(`${usuario.nombre}:`, usuario.perfil);

Un perfil nulo no contiene información suficiente para comparar direcciones y requerirá una estrategia alternativa.

48.6 Validación de dimensiones y valores

Antes de operar hay que verificar que los datos sean numéricos, finitos y compatibles. Detectar el problema al cargar el catálogo produce errores mucho más claros.

function validarVector(vector, dimension, minimo = 0, maximo = 5) {
  if (!Array.isArray(vector) || vector.length !== dimension) return false;
  return vector.every(x => Number.isFinite(x) && x >= minimo && x <= maximo);
}

console.log(validarVector([4, 5, 1, 5], 4));    // true
console.log(validarVector([4, 8, 1, 5], 4));    // false
console.log(validarVector([4, 5, 1], 4));       // false

48.7 Ponderación de características

No todas las dimensiones tienen que influir por igual. Un vector w permite asignar mayor importancia a algunas características.

producto ponderado: ⟨a, b⟩w = Σwiaibi

Para usar el producto escalar habitual se transforma cada componente multiplicándola por √wi.

function aplicarPesos(vector, pesos) {
  return vector.map((valor, i) => valor * Math.sqrt(pesos[i]));
}

const perfil = [4, 5, 1, 5];
const pesos = [1, 1.5, 0.5, 2];
console.log(aplicarPesos(perfil, pesos));

Los pesos deben ser no negativos. Si todos son cero, ninguna dimensión aporta información.

48.8 Normalización de vectores

Normalizar divide cada vector por su norma. El resultado tiene longitud 1 y conserva la dirección.

v̂ = v/||v||
function normalizar(vector) {
  const norma = Math.hypot(...vector);
  return norma === 0 ? null : vector.map(x => x / norma);
}

const unitario = normalizar([3, 4]);
console.log(unitario);                // [0.6, 0.8]
console.log(Math.hypot(...unitario)); // 1

Al normalizar, la recomendación depende del patrón relativo de intereses y no de la magnitud total del perfil.

48.9 Similitud coseno

La similitud coseno mide el ángulo entre dos vectores. Para datos no negativos, toma valores entre 0 y 1.

cos(a, b) = (a · b)/(||a|| ||b||)
function similitudCoseno(a, b) {
  const producto = a.reduce((s, x, i) => s + x * b[i], 0);
  const denominador = Math.hypot(...a) * Math.hypot(...b);
  return denominador === 0 ? 0 : producto / denominador;
}

console.log(similitudCoseno([1, 0], [1, 0])); // 1
console.log(similitudCoseno([1, 0], [0, 1])); // 0

48.10 Puntuación matricial

Si las filas de C y el perfil u están normalizados, todas las similitudes se calculan mediante un producto matriz-vector:

s = Cû     donde s contiene una puntuación por recurso
function matrizPorVector(matriz, vector) {
  return matriz.map(fila =>
    fila.reduce((s, valor, i) => s + valor * vector[i], 0)
  );
}

const catalogoNormalizado = [[1, 0], [0.6, 0.8], [0, 1]];
console.log(matrizPorVector(catalogoNormalizado, [0.8, 0.6]));
// [0.8, 0.96, 0.6]

48.11 Cálculo de puntuaciones ponderadas

La transformación por la raíz de los pesos y la similitud coseno se combinan en una función. Así, el numerador utiliza wiaibi y las normas respetan la misma métrica.

function cosenoPonderado(a, b, pesos) {
  const aw = a.map((x, i) => x * Math.sqrt(pesos[i]));
  const bw = b.map((x, i) => x * Math.sqrt(pesos[i]));
  const producto = aw.reduce((s, x, i) => s + x * bw[i], 0);
  const denominador = Math.hypot(...aw) * Math.hypot(...bw);
  return denominador === 0 ? 0 : producto / denominador;
}

console.log(cosenoPonderado([4, 5, 1, 5], [3, 4, 0, 5], [1, 1, 0.5, 2]));

48.12 Filtros y restricciones

Una gran similitud no vuelve válido un recurso. Deben aplicarse reglas como disponibilidad, idioma, requisitos previos y exclusión de contenidos ya completados.

const recursos = [
  { id: 1, activo: true, nivel: 1 },
  { id: 2, activo: false, nivel: 2 },
  { id: 3, activo: true, nivel: 3 }
];
const completados = new Set([1]);
const nivelUsuario = 2;

const candidatos = recursos.filter(r =>
  r.activo && !completados.has(r.id) && r.nivel <= nivelUsuario);
console.log(candidatos); // []

En este ejemplo no queda ningún candidato: el recurso 1 ya fue completado, el 2 está inactivo y el 3 supera el nivel permitido.

48.13 Ordenamiento y selección de los mejores

Luego de puntuar los candidatos se ordenan de mayor a menor. Conviene definir un desempate estable, por ejemplo mediante el identificador.

const puntuados = [
  { id: 3, nombre: "Datos", puntuacion: 0.92 },
  { id: 1, nombre: "Matrices", puntuacion: 0.88 },
  { id: 2, nombre: "Gráficos", puntuacion: 0.92 }
];

const top = [...puntuados]
  .sort((a, b) => b.puntuacion - a.puntuacion || a.id - b.id)
  .slice(0, 2);
console.log(top);

No se debe ordenar directamente una colección compartida si otras partes del programa dependen de su orden original.

48.14 Explicación de una recomendación

El numerador del coseno puede descomponerse por característica. Las contribuciones wiuici ayudan a explicar qué coincidencias favorecieron un recurso.

function contribuciones(usuario, recurso, pesos, nombres) {
  return nombres.map((nombre, i) => ({
    nombre,
    aporte: pesos[i] * usuario[i] * recurso[i]
  })).sort((a, b) => b.aporte - a.aporte);
}

console.log(contribuciones(
  [4, 5, 1, 5], [3, 4, 0, 5], [1, 1, 0.5, 2],
  ["álgebra", "programación", "gráficos", "datos"]
));

Estas contribuciones explican la puntuación matemática, no demuestran que el recurso sea pedagógicamente adecuado.

48.15 Diversidad de resultados

Un ranking basado solo en relevancia puede devolver recursos casi idénticos. Una estrategia sencilla resta una penalización por similitud con los elementos ya seleccionados.

puntuación ajustada = relevancia − λ · máxima similitud con los seleccionados
function puntuacionDiversa(relevancia, similitudesConElegidos, lambda) {
  const redundancia = similitudesConElegidos.length === 0
    ? 0 : Math.max(...similitudesConElegidos);
  return relevancia - lambda * redundancia;
}

console.log(puntuacionDiversa(0.95, [0.9, 0.4], 0.2)); // 0.77

λ = 0 prioriza solo relevancia; valores mayores aumentan la diversidad.

48.16 Actualización con retroalimentación

Cuando el usuario valora positivamente un recurso, su perfil puede desplazarse hacia el vector del recurso mediante una combinación convexa.

unuevo = (1 − α)u + αr     con 0 ≤ α ≤ 1
function actualizarPerfil(perfil, recurso, alpha) {
  const a = Math.max(0, Math.min(1, alpha));
  return perfil.map((valor, i) => (1 - a) * valor + a * recurso[i]);
}

console.log(actualizarPerfil([4, 2, 1, 3], [2, 5, 4, 1], 0.2));
// [3.6, 2.6, 1.6, 2.6]

Una tasa pequeña produce adaptación gradual. También deben admitirse señales negativas, olvido temporal y controles para el usuario.

48.17 Evaluación del recomendador

Precision@k mide qué proporción de los primeros k resultados fue relevante según datos de evaluación.

Precision@k = relevantes encontrados entre los k primeros / k
function precisionEnK(recomendados, relevantes, k) {
  const primeros = recomendados.slice(0, k);
  if (primeros.length === 0) return 0;
  const aciertos = primeros.filter(id => relevantes.has(id)).length;
  return aciertos / primeros.length;
}

console.log(precisionEnK([4, 2, 7], new Set([2, 4, 9]), 3)); // 2/3

También interesa medir cobertura, diversidad, novedad, satisfacción y rendimiento por distintos grupos. La evaluación debe usar interacciones que no participaron en la construcción del perfil.

48.18 Implementación completa

La siguiente versión integra validación, ponderación, similitud, filtros, explicaciones y ranking. Puede ampliarse sin cambiar el modelo matemático central.

const caracteristicas = ["álgebra", "programación", "gráficos", "datos"];
const catalogo = [
  { id: 1, nombre: "Matrices desde cero", vector: [5, 2, 0, 1], activo: true },
  { id: 2, nombre: "Gráficos 3D", vector: [3, 5, 5, 1], activo: true },
  { id: 3, nombre: "Análisis de datos", vector: [3, 4, 0, 5], activo: true },
  { id: 4, nombre: "IA práctica", vector: [4, 5, 2, 5], activo: true }
];

function validar(vector, dimension) {
  return Array.isArray(vector) && vector.length === dimension &&
    vector.every(x => Number.isFinite(x) && x >= 0 && x <= 5);
}

function cosenoPonderado(a, b, pesos) {
  let producto = 0, normaA2 = 0, normaB2 = 0;
  a.forEach((x, i) => {
    producto += pesos[i] * x * b[i];
    normaA2 += pesos[i] * x * x;
    normaB2 += pesos[i] * b[i] * b[i];
  });
  const denominador = Math.sqrt(normaA2 * normaB2);
  return denominador === 0 ? 0 : producto / denominador;
}

function recomendar(perfil, recursos, opciones = {}) {
  const pesos = opciones.pesos ?? Array(perfil.length).fill(1);
  const excluidos = opciones.excluidos ?? new Set();
  const cantidad = opciones.cantidad ?? 3;

  if (!validar(perfil, caracteristicas.length)) {
    throw new Error("Perfil inválido");
  }
  if (!validar(pesos, caracteristicas.length) || pesos.every(x => x === 0)) {
    throw new Error("Pesos inválidos");
  }

  return recursos
    .filter(r => r.activo && !excluidos.has(r.id) &&
      validar(r.vector, caracteristicas.length))
    .map(r => {
      const puntuacion = cosenoPonderado(perfil, r.vector, pesos);
      const aportes = caracteristicas.map((nombre, i) => ({
        nombre,
        valor: pesos[i] * perfil[i] * r.vector[i]
      })).sort((a, b) => b.valor - a.valor);
      return { id: r.id, nombre: r.nombre, puntuacion, motivo: aportes[0].nombre };
    })
    .sort((a, b) => b.puntuacion - a.puntuacion || a.id - b.id)
    .slice(0, cantidad);
}

const resultado = recomendar([4, 5, 1, 5], catalogo, {
  pesos: [1, 1.2, 0.5, 1.5],
  excluidos: new Set([1]),
  cantidad: 3
});
console.log(resultado);

Para catálogos grandes se precomputan vectores normalizados, se usan arreglos tipados o bibliotecas numéricas y se buscan vecinos aproximados mediante índices especializados.

48.19 Qué debes recordar del proyecto

  • El usuario y los recursos deben compartir el mismo espacio de características.
  • La matriz del catálogo reúne un recurso por fila.
  • Las dimensiones y los valores deben validarse antes de operar.
  • La raíz de los pesos transforma correctamente una métrica euclídea ponderada.
  • La normalización separa dirección y magnitud.
  • Las similitudes de todo el catálogo pueden verse como un producto matriz-vector.
  • Los filtros de negocio se aplican además de la puntuación algebraica.
  • Las contribuciones por característica ayudan a explicar resultados.
  • Relevancia, diversidad y aprendizaje del perfil implican decisiones de diseño.
  • Un recomendador debe evaluarse con datos no usados para construirlo.

48.20 Conclusión del curso

Este proyecto muestra cómo conceptos estudiados por separado forman un sistema completo: los vectores representan entidades, las matrices organizan el catálogo, el producto escalar mide afinidad y las transformaciones adaptan el espacio al problema.

El álgebra lineal no es solamente una colección de fórmulas. Es una forma de modelar información y construir soluciones para gráficos, videojuegos, imágenes, visión por computadora, ciencia de datos e inteligencia artificial.