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.
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.
El proyecto integra vectores, matrices, norma, producto escalar, normalización, transformaciones, ranking y evaluación.
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.
| Entrada | Representación |
|---|---|
| Perfil del usuario | Vector de 4 componentes |
| Catálogo de n recursos | Matriz n × 4 |
| Puntuaciones | Vector de n componentes |
| Resultado | Lista ordenada de recursos |
Todos los vectores deben respetar exactamente el mismo orden 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
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
El perfil u utiliza las mismas cuatro coordenadas. Puede obtenerse mediante preferencias declaradas o estimarse a partir del historial.
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.
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
No todas las dimensiones tienen que influir por igual. Un vector w permite asignar mayor importancia a algunas características.
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.
Normalizar divide cada vector por su norma. El resultado tiene longitud 1 y conserva la dirección.
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.
La similitud coseno mide el ángulo entre dos vectores. Para datos no negativos, toma valores entre 0 y 1.
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
Si las filas de C y el perfil u están normalizados, todas las similitudes se calculan mediante un producto matriz-vector:
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]
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]));
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.
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.
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.
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.
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.
Cuando el usuario valora positivamente un recurso, su perfil puede desplazarse hacia el vector del recurso mediante una combinación convexa.
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.
Precision@k mide qué proporción de los primeros k resultados fue relevante según datos de evaluación.
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.
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.
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.