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/plotProject 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));567Console 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.