06
Sentiment Analysis
Classifies 500 synthetic reviews as positive or negative. Reviews are turned into TF-IDF vectors, and two classifiers are compared.
Features
- Synthetic reviews built from fixed lists of positive, negative and neutral words, with 15% of the sentiment words flipped to the opposite class
- Vocabulary, bag-of-words counts and TF-IDF weights written by hand in
index.ts. Project 08 shows theTfidfVectorizerfromdeepbox/preprocessinstead LogisticRegressionandGaussianNBon standardized features- Accuracy, precision, recall, F1 and a confusion matrix
- Most positive and most negative words by smoothed count ratio
What you will learn
- Synthetic reviews built from fixed lists of positive, negative and neutral words, with 15% of the sentiment words flipped to the opposite class
- Vocabulary, bag-of-words counts and TF-IDF weights written by hand in
index.ts. Project 08 shows theTfidfVectorizerfromdeepbox/preprocessinstead LogisticRegressionandGaussianNBon standardized features- Accuracy, precision, recall, F1 and a confusion matrix
- Use deepbox/ml for
LogisticRegression,GaussianNB. - Use deepbox/preprocess for
StandardScaler,trainTestSplit.
Source
/** * Sentiment Analysis System * * Classifies synthetic reviews as positive or negative. Reviews are turned into * TF-IDF vectors by hand, then two classifiers are trained and compared. * * Deepbox Modules Used: * - deepbox/ml: LogisticRegression, GaussianNB * - deepbox/preprocess: StandardScaler, trainTestSplit * - deepbox/metrics: Classification metrics * - deepbox/dataframe: Tables for console output * - deepbox/ndarray: tensor * - deepbox/plot: SVG charts */ import { existsSync, mkdirSync, writeFileSync } from "node:fs";import { DataFrame } from "deepbox/dataframe";import { accuracy, confusionMatrix, f1Score, precision, recall } from "deepbox/metrics";import { GaussianNB, LogisticRegression } from "deepbox/ml";import { tensor } from "deepbox/ndarray";import { Figure } from "deepbox/plot";import { StandardScaler, trainTestSplit } from "deepbox/preprocess"; // ============================================================================// Configuration// ============================================================================ const OUTPUT_DIR = "docs/projects/06-sentiment-analysis/output";const NUM_SAMPLES = 500;const VOCAB_SIZE = 100; // ============================================================================// Data Generation & Text Processing// ============================================================================ /** * Sample positive and negative word lists for sentiment */const POSITIVE_WORDS = [ "good", "great", "excellent", "amazing", "wonderful", "fantastic", "awesome", "love", "like", "enjoy", "happy", "pleased", "satisfied", "recommend", "best", "perfect", "brilliant", "outstanding", "superb", "delightful", "impressive", "beautiful", "incredible", "exceptional", "remarkable", "terrific", "fabulous",]; const NEGATIVE_WORDS = [ "bad", "terrible", "awful", "horrible", "poor", "worst", "hate", "dislike", "disappointed", "frustrating", "annoying", "waste", "boring", "slow", "broken", "useless", "pathetic", "dreadful", "disgusting", "miserable", "unpleasant", "disappointing", "inadequate", "inferior", "subpar", "mediocre", "defective",]; const NEUTRAL_WORDS = [ "the", "a", "an", "is", "was", "it", "this", "that", "with", "for", "on", "product", "service", "item", "order", "delivery", "quality", "price", "time", "experience", "customer", "support", "would", "could", "should", "have", "been",]; /** * Generate synthetic review text */function generateReview(isPositive: boolean, seed: number): { text: string; words: string[] } { let randomSeed = seed; const seededRandom = () => { randomSeed = (randomSeed * 1103515245 + 12345) & 0x7fffffff; return randomSeed / 0x7fffffff; }; const numWords = Math.floor(seededRandom() * 15) + 10; // 10-25 words const words: string[] = []; const primaryWords = isPositive ? POSITIVE_WORDS : NEGATIVE_WORDS; const secondaryWords = isPositive ? NEGATIVE_WORDS : POSITIVE_WORDS; for (let i = 0; i < numWords; i++) { const r = seededRandom(); if (r < 0.3) { // 30% sentiment words (mostly matching sentiment) if (seededRandom() < 0.85) { words.push(primaryWords[Math.floor(seededRandom() * primaryWords.length)]); } else { words.push(secondaryWords[Math.floor(seededRandom() * secondaryWords.length)]); } } else { // 70% neutral words words.push(NEUTRAL_WORDS[Math.floor(seededRandom() * NEUTRAL_WORDS.length)]); } } return { text: words.join(" "), words };} /** * Build vocabulary from reviews */function buildVocabulary(reviews: string[][], maxSize: number): string[] { const wordCounts: Map<string, number> = new Map(); for (const words of reviews) { for (const word of words) { wordCounts.set(word, (wordCounts.get(word) || 0) + 1); } } // Sort by frequency and take top words const sorted = Array.from(wordCounts.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, maxSize); return sorted.map(([word]) => word);} /** * Convert text to bag-of-words vector */function textToVector(words: string[], vocabulary: string[]): number[] { const vector = new Array(vocabulary.length).fill(0); for (const word of words) { const idx = vocabulary.indexOf(word); if (idx >= 0) { vector[idx]++; } } return vector;} /** * Calculate TF-IDF weights */function calculateTFIDF(documents: number[][], vocabulary: string[]): number[][] { const numDocs = documents.length; const vocabSize = vocabulary.length; // Calculate document frequency for each term const df = new Array(vocabSize).fill(0); for (const doc of documents) { for (let i = 0; i < vocabSize; i++) { if (doc[i] > 0) { df[i]++; } } } // Calculate TF-IDF const tfidf: number[][] = []; for (const doc of documents) { const docLength = doc.reduce((a, b) => a + b, 0); const tfidfDoc: number[] = []; for (let i = 0; i < vocabSize; i++) { const tf = docLength > 0 ? doc[i] / docLength : 0; const idf = df[i] > 0 ? Math.log(numDocs / df[i]) : 0; tfidfDoc.push(tf * idf); } tfidf.push(tfidfDoc); } return tfidf;} // ============================================================================// Main Execution// ============================================================================ console.log("═".repeat(70));console.log(" SENTIMENT ANALYSIS SYSTEM");console.log(" Built with Deepbox: TypeScript toolkit for AI & numerical computing");console.log("═".repeat(70)); // Create output directoryif (!existsSync(OUTPUT_DIR)) { mkdirSync(OUTPUT_DIR, { recursive: true });} // ============================================================================// Step 1: Generate Data// ============================================================================ console.log("\nSTEP 1: Generating Sentiment Data");console.log("─".repeat(70)); const reviews: { text: string; words: string[]; label: number }[] = []; for (let i = 0; i < NUM_SAMPLES; i++) { const isPositive = i < NUM_SAMPLES / 2; const { text, words } = generateReview(isPositive, i + 42); reviews.push({ text, words, label: isPositive ? 1 : 0 });} // The reviews are ordered (positive first). trainTestSplit shuffles with a fixed seed. const numPositive = reviews.filter((r) => r.label === 1).length;const numNegative = reviews.filter((r) => r.label === 0).length; console.log(`\nGenerated ${NUM_SAMPLES} reviews`);console.log(` Positive: ${numPositive} (${((numPositive / NUM_SAMPLES) * 100).toFixed(1)}%)`);console.log(` Negative: ${numNegative} (${((numNegative / NUM_SAMPLES) * 100).toFixed(1)}%)`); // Sample reviewsconsole.log("\nSample Reviews:");console.log(` [Positive] "${reviews.find((r) => r.label === 1)?.text.slice(0, 60)}..."`);console.log(` [Negative] "${reviews.find((r) => r.label === 0)?.text.slice(0, 60)}..."`); // ============================================================================// Step 2: Build Vocabulary & Features// ============================================================================ console.log("\nSTEP 2: Building Vocabulary & Features");console.log("─".repeat(70)); // Build vocabularyconst allWords = reviews.map((r) => r.words);const vocabulary = buildVocabulary(allWords, VOCAB_SIZE); console.log(`\nBuilt vocabulary with ${vocabulary.length} terms`);console.log(` Top terms: ${vocabulary.slice(0, 10).join(", ")}...`); // Convert to bag-of-wordsconst bowVectors = reviews.map((r) => textToVector(r.words, vocabulary)); // Calculate TF-IDFconst tfidfVectors = calculateTFIDF(bowVectors, vocabulary); console.log(`Created TF-IDF vectors`);console.log(` Vector dimension: ${tfidfVectors[0].length}`); // Prepare dataconst X = tensor(tfidfVectors);const y = tensor(reviews.map((r) => r.label)); // ============================================================================// Step 3: Train/Test Split// ============================================================================ console.log("\nSTEP 3: Train/Test Split");console.log("─".repeat(70)); const [XTrain, XTest, yTrain, yTest] = trainTestSplit(X, y, { testSize: 0.2, randomState: 42, shuffle: true,}); console.log(`\nSplit data`);console.log(` Training: ${XTrain.shape[0]} samples`);console.log(` Testing: ${XTest.shape[0]} samples`); // Scale featuresconst scaler = new StandardScaler();scaler.fit(XTrain);const XTrainScaled = scaler.transform(XTrain);const XTestScaled = scaler.transform(XTest); console.log(`Applied StandardScaler`); // ============================================================================// Step 4: Model Training// ============================================================================ console.log("\nSTEP 4: Model Training");console.log("─".repeat(70)); // Logistic Regressionconsole.log("\nTraining Logistic Regression...");const lr = new LogisticRegression({ maxIter: 200, learningRate: 0.1 });const lrStart = Date.now();lr.fit(XTrainScaled, yTrain);const lrTime = Date.now() - lrStart;console.log(` Trained in ${lrTime}ms`); // Naive Bayesconsole.log("Training Gaussian Naive Bayes...");const nb = new GaussianNB();const nbStart = Date.now();nb.fit(XTrainScaled, yTrain);const nbTime = Date.now() - nbStart;console.log(` Trained in ${nbTime}ms`); // ============================================================================// Step 5: Model Evaluation// ============================================================================ console.log("\nSTEP 5: Model Evaluation");console.log("─".repeat(70)); const yPredLR = lr.predict(XTestScaled);const yPredNB = nb.predict(XTestScaled); // Metrics on the held-out test setconst results = [ { name: "Logistic Regression", accuracy: accuracy(yTest, yPredLR), precision: precision(yTest, yPredLR, "binary"), recall: recall(yTest, yPredLR, "binary"), f1: f1Score(yTest, yPredLR, "binary"), predictions: yPredLR, time: lrTime, }, { name: "Gaussian Naive Bayes", accuracy: accuracy(yTest, yPredNB), precision: precision(yTest, yPredNB, "binary"), recall: recall(yTest, yPredNB, "binary"), f1: f1Score(yTest, yPredNB, "binary"), predictions: yPredNB, time: nbTime, },]; console.log("\nModel Comparison:\n");const metricsDF = new DataFrame({ Model: results.map((r) => r.name), "Accuracy (%)": results.map((r) => (r.accuracy * 100).toFixed(2)), "Precision (%)": results.map((r) => (r.precision * 100).toFixed(2)), "Recall (%)": results.map((r) => (r.recall * 100).toFixed(2)), "F1 Score (%)": results.map((r) => (r.f1 * 100).toFixed(2)), "Time (ms)": results.map((r) => r.time.toString()),});console.log(metricsDF.toString()); // Best modelconst bestModel = results.reduce((best, r) => (r.f1 > best.f1 ? r : best));console.log(`\nBest Model: ${bestModel.name} (F1: ${(bestModel.f1 * 100).toFixed(2)}%)`); // ============================================================================// Step 6: Confusion Matrix// ============================================================================ console.log("\nSTEP 6: Confusion Matrix Analysis");console.log("─".repeat(70)); const cm = confusionMatrix(yTest, bestModel.predictions);const [[tn, fp], [fn, tp]] = cm.toArray() as number[][]; console.log(`\nConfusion Matrix (${bestModel.name}):`);console.log(" Predicted");console.log(" Negative Positive");console.log(` Actual Negative ${String(tn).padStart(4)} ${String(fp).padStart(4)}`);console.log(` Actual Positive ${String(fn).padStart(4)} ${String(tp).padStart(4)}`); console.log(`\n True Negatives: ${tn}`);console.log(` False Positives: ${fp}`);console.log(` False Negatives: ${fn}`);console.log(` True Positives: ${tp}`); // ============================================================================// Step 7: Feature Analysis// ============================================================================ console.log("\nSTEP 7: Feature Analysis");console.log("─".repeat(70)); // Analyze most predictive words (simple frequency analysis)const posWordCounts: Map<string, number> = new Map();const negWordCounts: Map<string, number> = new Map(); for (const review of reviews) { const countMap = review.label === 1 ? posWordCounts : negWordCounts; for (const word of review.words) { countMap.set(word, (countMap.get(word) || 0) + 1); }} // Find words with highest positive/negative ratioconst wordRatios: { word: string; ratio: number; posCount: number; negCount: number;}[] = [];for (const word of vocabulary) { const posCount = posWordCounts.get(word) || 0; const negCount = negWordCounts.get(word) || 0; if (posCount + negCount >= 5) { // Minimum frequency const ratio = (posCount + 1) / (negCount + 1); // Laplace smoothing wordRatios.push({ word, ratio, posCount, negCount }); }} // Most positive wordsconst mostPositive = wordRatios.sort((a, b) => b.ratio - a.ratio).slice(0, 5);// Most negative wordsconst mostNegative = wordRatios.sort((a, b) => a.ratio - b.ratio).slice(0, 5); console.log("\nMost Positive Words:");for (const { word, ratio, posCount, negCount } of mostPositive) { console.log(` "${word}": ratio=${ratio.toFixed(2)} (pos=${posCount}, neg=${negCount})`);} console.log("\nMost Negative Words:");for (const { word, ratio, posCount, negCount } of mostNegative) { console.log(` "${word}": ratio=${ratio.toFixed(2)} (pos=${posCount}, neg=${negCount})`);} // ============================================================================// Step 8: Sample Predictions// ============================================================================ console.log("\nSTEP 8: Sample Predictions");console.log("─".repeat(70)); // Show some predictionsconst testLabels = yTest.toArray() as number[];const predLabels = bestModel.predictions.toArray() as number[]; console.log("\nSample Test Predictions:\n");const sampleCount = Math.min(10, XTest.shape[0]);let sampleCorrect = 0;let totalCorrect = 0;let shown = 0; for (let i = 0; i < XTest.shape[0]; i++) { const actual = testLabels[i] === 1 ? "Positive" : "Negative"; const predicted = predLabels[i] === 1 ? "Positive" : "Negative"; const status = testLabels[i] === predLabels[i] ? "ok" : "wrong"; if (i < sampleCount && shown < 5) { console.log(` [${status}] Actual: ${actual.padEnd(10)} Predicted: ${predicted}`); shown++; } if (testLabels[i] === predLabels[i]) { totalCorrect++; if (i < sampleCount) { sampleCorrect++; } }} console.log(`\n Sample subset correct: ${sampleCorrect}/${sampleCount}`);console.log(` Full test correct: ${totalCorrect}/${XTest.shape[0]}`); // ============================================================================// Step 9: Visualizations// ============================================================================ console.log("\nSTEP 9: Generating Visualizations");console.log("─".repeat(70)); // Model comparison charttry { const fig = new Figure({ width: 800, height: 400 }); const ax = fig.addAxes(); const modelIndices = [0, 1]; const f1Scores = results.map((r) => r.f1 * 100); ax.bar(tensor(modelIndices), tensor(f1Scores), { color: "#4CAF50" }); ax.setTitle("Model Comparison (F1 Score)"); ax.setXLabel("Model"); ax.setYLabel("F1 Score (%)"); const svg = fig.renderSVG(); writeFileSync(`${OUTPUT_DIR}/model-comparison.svg`, svg.svg); console.log(` Saved: ${OUTPUT_DIR}/model-comparison.svg`);} catch (e) { console.log(` Warning: could not generate model comparison: ${e}`);} // ============================================================================// Summary// ============================================================================ console.log(`\n${"═".repeat(70)}`);console.log(" SENTIMENT ANALYSIS COMPLETE - SUMMARY");console.log("═".repeat(70)); console.log("\nKey Findings:\n");console.log(" 1. Data Overview:");console.log(` • ${NUM_SAMPLES} reviews analyzed`);console.log(` • Vocabulary size: ${vocabulary.length} terms`);console.log(` • Balanced classes (50/50)`); console.log("\n 2. Best Model:");console.log(` • ${bestModel.name}`);console.log(` • Accuracy: ${(bestModel.accuracy * 100).toFixed(2)}%`);console.log(` • F1 Score: ${(bestModel.f1 * 100).toFixed(2)}%`);console.log(` • Training time: ${bestModel.time}ms`); console.log("\n 3. Observations:");console.log(" • The reviews are drawn from fixed positive, negative and neutral word lists");console.log(" • High scores are expected on data this regular, and say little about real text"); console.log("\nOutput Files:");console.log(` • ${OUTPUT_DIR}/model-comparison.svg`); console.log(`\n${"═".repeat(70)}`);console.log(" Sentiment Analysis Complete!");console.log("═".repeat(70));Output
Model comparison, confusion matrix and sample predictions on the console
`output/model-comparison.svg`