Project 05
Tensors
ML
Metrics
DataFrame
Visualization

Movie Recommendation Engine

A collaborative filtering recommendation system using user-item ratings, clustering, and dimensionality reduction. It combines deepbox/ndarray, deepbox/ml, deepbox/metrics, deepbox/dataframe, deepbox/plot to deliver a larger production-style Deepbox workflow with reproducible outputs and documented architecture.

Features

  • User-Item Matrix: Dense ratings matrix with missing values encoded as zeros
  • Collaborative Filtering: User-based and item-based similarity
  • Clustering: K-means for user segmentation
  • Dimensionality Reduction: PCA for visualization

Deepbox Modules Used

deepbox/ndarraydeepbox/mldeepbox/metricsdeepbox/dataframedeepbox/plot

Project Architecture

    Source Files

    index.ts
    1/**2 * Movie Recommendation Engine3 *4 * Collaborative filtering recommendation system using clustering and similarity.5 *6 * Deepbox Modules Used:7 * - deepbox/ndarray: Tensor and sparse matrix operations8 * - deepbox/ml: KMeans, PCA, KNN9 * - deepbox/stats: Correlation for similarity10 * - deepbox/metrics: Clustering metrics11 * - deepbox/dataframe: Data manipulation12 */1314import { existsSync, mkdirSync, writeFileSync } from "node:fs";15import { isNumericTypedArray, isTypedArray } from "deepbox/core";16import { DataFrame } from "deepbox/dataframe";17import { silhouetteScore } from "deepbox/metrics";18import { KMeans, PCA } from "deepbox/ml";19import { tensor } from "deepbox/ndarray";20import { Figure } from "deepbox/plot";2122// ============================================================================23// Configuration24// ============================================================================2526const OUTPUT_DIR = "docs/projects/05-recommendation-engine/output";27const NUM_USERS = 200;28const NUM_MOVIES = 50;29const SPARSITY = 0.7; // 70% of ratings are missing30const NUM_RECOMMENDATIONS = 5;3132const expectNumericTypedArray = (33  value: unknown34): Float32Array | Float64Array | Int32Array | Uint8Array => {35  if (!isTypedArray(value) || !isNumericTypedArray(value)) {36    throw new Error("Expected numeric typed array");37  }38  return value;39};4041// ============================================================================42// Data Generation43// ============================================================================4445/**46 * Generate synthetic movie rating data47 */48function generateRatingData(49  numUsers: number,50  numMovies: number,51  sparsity: number,52  seed = 4253): {54  ratings: number[][];55  userIds: number[];56  movieIds: number[];57  movieGenres: string[];58  movieNames: string[];59} {60  let randomSeed = seed;61  const seededRandom = () => {62    randomSeed = (randomSeed * 1103515245 + 12345) & 0x7fffffff;63    return randomSeed / 0x7fffffff;64  };6566  const genres = ["Action", "Comedy", "Drama", "Sci-Fi", "Horror", "Romance", "Thriller"];67  const movieGenres = Array.from(68    { length: numMovies },69    () => genres[Math.floor(seededRandom() * genres.length)]70  );7172  const movieNames = Array.from({ length: numMovies }, (_, i) => `Movie ${i + 1}`);7374  // Create user preference profiles (latent factors)75  const userPrefs: number[][] = [];76  for (let u = 0; u < numUsers; u++) {77    // Each user has preferences for each genre78    const prefs = genres.map(() => seededRandom() * 2 - 0.5);79    userPrefs.push(prefs);80  }8182  // Generate ratings matrix83  const ratings: number[][] = [];84  for (let u = 0; u < numUsers; u++) {85    const userRatings: number[] = [];86    for (let m = 0; m < numMovies; m++) {87      if (seededRandom() < sparsity) {88        userRatings.push(0); // Missing rating89      } else {90        // Rating based on user preference for movie's genre91        const genreIdx = genres.indexOf(movieGenres[m]);92        const basePref = userPrefs[u][genreIdx];93        const rating = Math.round(94          Math.max(1, Math.min(5, 3 + basePref * 2 + (seededRandom() - 0.5)))95        );96        userRatings.push(rating);97      }98    }99    ratings.push(userRatings);100  }101102  return {103    ratings,104    userIds: Array.from({ length: numUsers }, (_, i) => i),105    movieIds: Array.from({ length: numMovies }, (_, i) => i),106    movieGenres,107    movieNames,108  };109}110111/**112 * Calculate cosine similarity between two vectors113 */114function cosineSimilarity(a: number[], b: number[]): number {115  let dotProduct = 0;116  let normA = 0;117  let normB = 0;118119  for (let i = 0; i < a.length; i++) {120    if (a[i] !== 0 && b[i] !== 0) {121      dotProduct += a[i] * b[i];122    }123    normA += a[i] * a[i];124    normB += b[i] * b[i];125  }126127  if (normA === 0 || normB === 0) return 0;128  return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));129}130131/**132 * Calculate adjusted cosine similarity (for item-based CF)133 */134function adjustedCosineSimilarity(ratings: number[][], item1: number, item2: number): number {135  const numUsers = ratings.length;136  let sum = 0;137  let norm1 = 0;138  let norm2 = 0;139  let count = 0;140141  // Calculate user means142  const userMeans = ratings.map((userRatings) => {143    const nonZero = userRatings.filter((r) => r > 0);144    return nonZero.length > 0 ? nonZero.reduce((a, b) => a + b, 0) / nonZero.length : 0;145  });146147  for (let u = 0; u < numUsers; u++) {148    const r1 = ratings[u][item1];149    const r2 = ratings[u][item2];150151    if (r1 > 0 && r2 > 0) {152      const adj1 = r1 - userMeans[u];153      const adj2 = r2 - userMeans[u];154      sum += adj1 * adj2;155      norm1 += adj1 * adj1;156      norm2 += adj2 * adj2;157      count++;158    }159  }160161  if (count < 2 || norm1 === 0 || norm2 === 0) return 0;162  return sum / (Math.sqrt(norm1) * Math.sqrt(norm2));163}164165/**166 * Get top-k similar users167 */168function getTopKSimilarUsers(169  ratings: number[][],170  targetUser: number,171  k: number172): { userId: number; similarity: number }[] {173  const similarities: { userId: number; similarity: number }[] = [];174175  for (let u = 0; u < ratings.length; u++) {176    if (u !== targetUser) {177      const sim = cosineSimilarity(ratings[targetUser], ratings[u]);178      if (sim > 0) {179        similarities.push({ userId: u, similarity: sim });180      }181    }182  }183184  return similarities.sort((a, b) => b.similarity - a.similarity).slice(0, k);185}186187/**188 * Predict rating using user-based collaborative filtering189 */190function predictRating(191  ratings: number[][],192  targetMovie: number,193  similarUsers: { userId: number; similarity: number }[]194): number {195  let weightedSum = 0;196  let simSum = 0;197198  for (const { userId, similarity } of similarUsers) {199    const rating = ratings[userId][targetMovie];200    if (rating > 0) {201      weightedSum += similarity * rating;202      simSum += similarity;203    }204  }205206  if (simSum === 0) return 0;207  return weightedSum / simSum;208}209210// ============================================================================211// Main Execution212// ============================================================================213214console.log("═".repeat(70));215console.log("  MOVIE RECOMMENDATION ENGINE");216console.log("  Built with Deepbox — TypeScript toolkit for AI & numerical computing");217console.log("═".repeat(70));218219// Create output directory220if (!existsSync(OUTPUT_DIR)) {221  mkdirSync(OUTPUT_DIR, { recursive: true });222}223224// ============================================================================225// Step 1: Generate Data226// ============================================================================227228console.log("\n📊 STEP 1: Generating Rating Data");229console.log("─".repeat(70));230231const { ratings, movieGenres, movieNames } = generateRatingData(NUM_USERS, NUM_MOVIES, SPARSITY);232233// Calculate statistics234let totalRatings = 0;235let ratingSum = 0;236const ratingCounts = [0, 0, 0, 0, 0]; // counts for ratings 1-5237238for (const userRatings of ratings) {239  for (const rating of userRatings) {240    if (rating > 0) {241      totalRatings++;242      ratingSum += rating;243      ratingCounts[rating - 1]++;244    }245  }246}247248console.log(`\n✓ Generated rating matrix`);249console.log(`  Users: ${NUM_USERS}`);250console.log(`  Movies: ${NUM_MOVIES}`);251console.log(`  Total Ratings: ${totalRatings}`);252console.log(`  Sparsity: ${((1 - totalRatings / (NUM_USERS * NUM_MOVIES)) * 100).toFixed(1)}%`);253console.log(`  Average Rating: ${(ratingSum / totalRatings).toFixed(2)}`);254255console.log(`\nRating Distribution:`);256for (let i = 0; i < 5; i++) {257  const pct = ((ratingCounts[i] / totalRatings) * 100).toFixed(1);258  const bar = "█".repeat(Math.round((ratingCounts[i] / totalRatings) * 30));259  console.log(`  ${i + 1} stars: ${bar} ${pct}%`);260}261262// Genre distribution263const genreCounts: { [key: string]: number } = {};264for (const genre of movieGenres) {265  genreCounts[genre] = (genreCounts[genre] || 0) + 1;266}267console.log(`\nMovie Genres:`);268for (const [genre, count] of Object.entries(genreCounts).sort((a, b) => b[1] - a[1])) {269  console.log(`  ${genre}: ${count} movies`);270}271272// ============================================================================273// Step 2: User Clustering274// ============================================================================275276console.log("\n👥 STEP 2: User Clustering (K-Means)");277console.log("─".repeat(70));278279// Create user feature matrix (replace 0s with mean for clustering)280const userFeatures: number[][] = ratings.map((userRatings) => {281  const nonZero = userRatings.filter((r) => r > 0);282  const userMean = nonZero.length > 0 ? nonZero.reduce((a, b) => a + b, 0) / nonZero.length : 3;283  return userRatings.map((r) => (r === 0 ? userMean : r));284});285286const userFeaturesTensor = tensor(userFeatures);287288// Try different k values289console.log("\nFinding optimal number of clusters...");290const kValues = [3, 4, 5, 6];291const clusterResults: { k: number; inertia: number; silhouette: number }[] = [];292293for (const k of kValues) {294  const kmeans = new KMeans({ nClusters: k, randomState: 42 });295  kmeans.fit(userFeaturesTensor);296  const labels = kmeans.predict(userFeaturesTensor);297298  let silhouette = 0;299  try {300    silhouette = silhouetteScore(userFeaturesTensor, labels);301  } catch (_e) {302    silhouette = 0;303  }304305  clusterResults.push({306    k,307    inertia: kmeans.inertia,308    silhouette: Number(silhouette),309  });310311  console.log(312    `  k=${k}: Inertia=${kmeans.inertia.toFixed(2)}, Silhouette=${Number(silhouette).toFixed(3)}`313  );314}315316// Select best k based on silhouette score317const bestK = clusterResults.reduce((best, r) => (r.silhouette > best.silhouette ? r : best)).k;318console.log(`\n✓ Best k=${bestK} selected`);319320// Final clustering321const kmeans = new KMeans({ nClusters: bestK, randomState: 42 });322kmeans.fit(userFeaturesTensor);323const userLabels = kmeans.predict(userFeaturesTensor);324const labelData = expectNumericTypedArray(userLabels.data);325326// Cluster statistics327console.log(`\nCluster Distribution:`);328for (let c = 0; c < bestK; c++) {329  const clusterUsers = Array.from(labelData).filter((l) => l === c).length;330  console.log(331    `  Cluster ${c}: ${clusterUsers} users (${((clusterUsers / NUM_USERS) * 100).toFixed(1)}%)`332  );333}334335// ============================================================================336// Step 3: Dimensionality Reduction (PCA)337// ============================================================================338339console.log("\n📉 STEP 3: Dimensionality Reduction (PCA)");340console.log("─".repeat(70));341342const pca = new PCA({ nComponents: 2 });343pca.fit(userFeaturesTensor);344const userProjected = pca.transform(userFeaturesTensor);345346console.log(`\n✓ Reduced ${NUM_MOVIES} features to 2 components`);347const explainedVar = expectNumericTypedArray(pca.explainedVarianceRatio.data);348console.log(`  Component 1 variance: ${(explainedVar[0] * 100).toFixed(1)}%`);349console.log(`  Component 2 variance: ${(explainedVar[1] * 100).toFixed(1)}%`);350console.log(`  Total explained: ${((explainedVar[0] + explainedVar[1]) * 100).toFixed(1)}%`);351352// ============================================================================353// Step 4: User-Based Collaborative Filtering354// ============================================================================355356console.log("\n🎯 STEP 4: User-Based Collaborative Filtering");357console.log("─".repeat(70));358359// Select a target user for demonstration360const targetUser = 0;361console.log(`\nGenerating recommendations for User ${targetUser}...`);362363// Find similar users364const similarUsers = getTopKSimilarUsers(ratings, targetUser, 20);365console.log(`\nTop 5 Similar Users:`);366for (const { userId, similarity } of similarUsers.slice(0, 5)) {367  console.log(`  User ${userId}: similarity = ${similarity.toFixed(3)}`);368}369370// Find movies the user hasn't rated371const unratedMovies: number[] = [];372for (let m = 0; m < NUM_MOVIES; m++) {373  if (ratings[targetUser][m] === 0) {374    unratedMovies.push(m);375  }376}377378// Predict ratings for unrated movies379const predictions: { movieId: number; predictedRating: number }[] = [];380for (const movieId of unratedMovies) {381  const predicted = predictRating(ratings, movieId, similarUsers);382  if (predicted > 0) {383    predictions.push({ movieId, predictedRating: predicted });384  }385}386387// Sort by predicted rating388predictions.sort((a, b) => b.predictedRating - a.predictedRating);389390console.log(`\nTop ${NUM_RECOMMENDATIONS} Recommendations for User ${targetUser}:`);391const recDF = new DataFrame({392  Rank: predictions.slice(0, NUM_RECOMMENDATIONS).map((_, i) => i + 1),393  Movie: predictions.slice(0, NUM_RECOMMENDATIONS).map((p) => movieNames[p.movieId]),394  Genre: predictions.slice(0, NUM_RECOMMENDATIONS).map((p) => movieGenres[p.movieId]),395  "Predicted Rating": predictions396    .slice(0, NUM_RECOMMENDATIONS)397    .map((p) => p.predictedRating.toFixed(2)),398});399console.log(recDF.toString());400401// ============================================================================402// Step 5: Item-Based Similarity403// ============================================================================404405console.log("\n🎬 STEP 5: Item-Based Similarity Analysis");406console.log("─".repeat(70));407408// Calculate item similarity matrix (sample)409console.log("\nCalculating movie similarities...");410const sampleMovies = [0, 10, 20, 30, 40];411const itemSimilarities: number[][] = [];412413for (const i of sampleMovies) {414  const row: number[] = [];415  for (const j of sampleMovies) {416    if (i === j) {417      row.push(1.0);418    } else {419      row.push(adjustedCosineSimilarity(ratings, i, j));420    }421  }422  itemSimilarities.push(row);423}424425console.log(`\nItem Similarity Matrix (sample of ${sampleMovies.length} movies):`);426console.log(`        ${sampleMovies.map((m) => `M${m}`.padStart(6)).join("")}`);427for (let i = 0; i < sampleMovies.length; i++) {428  const row = `${`M${sampleMovies[i]}`.padStart(6)} `;429  console.log(row + itemSimilarities[i].map((s) => s.toFixed(2).padStart(6)).join(""));430}431432// Find similar movies for a sample movie433const sampleMovie = 0;434const movieSims: { movieId: number; similarity: number }[] = [];435for (let m = 0; m < NUM_MOVIES; m++) {436  if (m !== sampleMovie) {437    const sim = adjustedCosineSimilarity(ratings, sampleMovie, m);438    if (!Number.isNaN(sim) && sim > 0) {439      movieSims.push({ movieId: m, similarity: sim });440    }441  }442}443movieSims.sort((a, b) => b.similarity - a.similarity);444445console.log(`\nMovies similar to "${movieNames[sampleMovie]}" (${movieGenres[sampleMovie]}):`);446for (const { movieId, similarity } of movieSims.slice(0, 5)) {447  console.log(`  ${movieNames[movieId]} (${movieGenres[movieId]}): ${similarity.toFixed(3)}`);448}449450// ============================================================================451// Step 6: Evaluation452// ============================================================================453454console.log("\n📈 STEP 6: Recommendation Evaluation");455console.log("─".repeat(70));456457// Simple evaluation: predict known ratings and measure error458let totalError = 0;459let totalPredictions = 0;460461for (let u = 0; u < Math.min(50, NUM_USERS); u++) {462  const simUsers = getTopKSimilarUsers(ratings, u, 10);463464  for (let m = 0; m < NUM_MOVIES; m++) {465    if (ratings[u][m] > 0) {466      // Temporarily hide rating467      const actualRating = ratings[u][m];468      ratings[u][m] = 0;469470      const predicted = predictRating(ratings, m, simUsers);471472      // Restore rating473      ratings[u][m] = actualRating;474475      if (predicted > 0) {476        totalError += Math.abs(predicted - actualRating);477        totalPredictions++;478      }479    }480  }481}482483const mae = totalError / totalPredictions;484console.log(`\nCollaborative Filtering Evaluation (leave-one-out):`);485console.log(`  Predictions made: ${totalPredictions}`);486console.log(`  Mean Absolute Error: ${mae.toFixed(3)}`);487console.log(`  (Lower is better, random guess MAE ≈ 1.5)`);488489// ============================================================================490// Step 7: Visualizations491// ============================================================================492493console.log("\n📊 STEP 7: Generating Visualizations");494console.log("─".repeat(70));495496// User clusters visualization497try {498  const fig = new Figure({ width: 800, height: 600 });499  const ax = fig.addAxes();500501  const projData = expectNumericTypedArray(userProjected.data);502  const xCoords: number[] = [];503  const yCoords: number[] = [];504  for (let i = 0; i < NUM_USERS; i++) {505    xCoords.push(projData[i * 2]);506    yCoords.push(projData[i * 2 + 1]);507  }508509  ax.scatter(tensor(xCoords), tensor(yCoords), { color: "#2196F3", size: 5 });510  ax.setTitle("User Clusters (PCA Projection)");511  ax.setXLabel("PC1");512  ax.setYLabel("PC2");513514  const svg = fig.renderSVG();515  writeFileSync(`${OUTPUT_DIR}/user-clusters.svg`, svg.svg);516  console.log(`  ✓ Saved: ${OUTPUT_DIR}/user-clusters.svg`);517} catch (e) {518  console.log(`  ⚠ Could not generate user clusters plot: ${e}`);519}520521// Rating distribution522try {523  const fig = new Figure({ width: 800, height: 400 });524  const ax = fig.addAxes();525526  ax.bar(tensor([1, 2, 3, 4, 5]), tensor(ratingCounts), { color: "#FF9800" });527  ax.setTitle("Rating Distribution");528  ax.setXLabel("Rating");529  ax.setYLabel("Count");530531  const svg = fig.renderSVG();532  writeFileSync(`${OUTPUT_DIR}/rating-distribution.svg`, svg.svg);533  console.log(`  ✓ Saved: ${OUTPUT_DIR}/rating-distribution.svg`);534} catch (e) {535  console.log(`  ⚠ Could not generate rating distribution: ${e}`);536}537538// ============================================================================539// Summary540// ============================================================================541542console.log(`\n${"═".repeat(70)}`);543console.log("  RECOMMENDATION ENGINE COMPLETE - SUMMARY");544console.log("═".repeat(70));545546console.log("\n📌 Key Findings:\n");547console.log("  1. Data Overview:");548console.log(`     • ${NUM_USERS} users, ${NUM_MOVIES} movies`);549console.log(`     • ${totalRatings} ratings (${((1 - SPARSITY) * 100).toFixed(0)}% density)`);550console.log(`     • Average rating: ${(ratingSum / totalRatings).toFixed(2)}`);551552console.log("\n  2. User Clustering:");553console.log(`     • Optimal clusters: ${bestK}`);554console.log(`     • Users show distinct preference patterns`);555556console.log("\n  3. Recommendation Quality:");557console.log(`     • MAE: ${mae.toFixed(3)} (baseline ~1.5)`);558console.log(`     • Collaborative filtering captures user preferences`);559560console.log("\n📁 Output Files:");561console.log(`   • ${OUTPUT_DIR}/user-clusters.svg`);562console.log(`   • ${OUTPUT_DIR}/rating-distribution.svg`);563564console.log(`\n${"═".repeat(70)}`);565console.log("  ✅ Recommendation Engine Complete!");566console.log("═".repeat(70));567

    Console Output

    $ npx tsx 05-recommendation-engine/index.ts
    See the example README for the expected console and artifact output.

    Key Takeaways

    • User-Item Matrix: Dense ratings matrix with missing values encoded as zeros
    • Collaborative Filtering: User-based and item-based similarity
    • Clustering: K-means for user segmentation
    • Dimensionality Reduction: PCA for visualization
    • Use deepbox/ndarray for Tensor operations.
    • Use deepbox/ml for KMeans, PCA.