Skip to the analysis
GARETH GRANTTECHNOLOGY · DATA · INNOVATION Back to projects
Back to all projects

Portfolio case study · Sentiment analysis · NLP

From movie reviewsto better decisions.

A complete teaching workflow for sentiment analysis: audit the text, protect an independent test set, compare models and turn probabilities into decisions.

Gareth Grant · 5 October 2026 · Executed code, charts and interpretation

49,577unique valid reviews
90.98%held-out accuracy
0.9098macro F1
100explained code cells

Evidence note: The final evaluation used 9,916 reviews; the accuracy interval was 90.44–91.56%. The classical workflow is executed. Neural experiments remain optional and are not presented as trained results.

Table of contents
GARETH GRANTDATA ANALYTICS · PORTFOLIO
Explore the analysis01Question & design02Setup03Data quality04Independent splits05Training exploration06Text features07Classical models08Optional neural code09Model & threshold10Held-out results11Explain & inspect12Example predictions13Real-world adaptations14Exports & publishing15Exercises & sources
A reproducible NLP study · Gareth Grant

From movie reviews
to better decisions.

A complete teaching workflow for sentiment analysis: audit the data, build useful baselines, inspect the mistakes and connect the evidence to practical decisions.

Text analyticsInterpretable machine learningIndependent evaluation
49,577Unique valid reviews
90.98%Held-out accuracy
0.9098Held-out macro F1

9,916 test reviews · Accuracy interval 90.44%–91.56% · Local split, conditional on this trained model.

Movie reviews to decision support

An explainable sentiment analysis portfolio

Gareth Grant · Marketing, analytics and machine learning

This project asks whether movie-review text can distinguish positive from negative reviews, which method earns its complexity, and how the workflow could support a real organisation. It rebuilds three supplied PDAN 8412 notebooks into one teaching notebook.

Reading pattern: each numbered step has one purpose, one small code cell, and a guide to the result. Add your own comment below a code cell or create a Markdown cell beside it. Stable cell IDs also make individual steps easy to link from the HTML edition.

Execution status: the complete classical workflow has been executed on the supplied CSV. The optional TensorFlow pooling, SimpleRNN and LSTM experiments are provided as code, but were not trained or runtime-tested in this edition because TensorFlow was unavailable and its installation could not complete. No neural performance is claimed.

Skills demonstrated: data quality, leakage prevention, NLP, model comparison, validation, uncertainty, error analysis, explainability, reproducible exports and decision framing.

Contents

  1. Question and study design
  2. Setup
  3. Data audit and cleaning
  4. Split before learning
  5. Explore the training data
  6. Turn text into features
  7. Classical models
  8. Optional neural experiments
  9. Lock the model and operating rule
  10. Independent final evaluation
  11. Explain and inspect errors
  12. Predict new reviews
  13. Business, astronomy and government
  14. Reproducibility and publishing
  15. Practice and references

Question and study design

Question: how reliably can a model classify strongly polarised English movie reviews, and what does it miss when used to flag negative feedback?

Item Choice Reason
Input Review text only Sentiment must not be supplied as a feature.
Target Negative = 0; positive = 1 A binary task with an explicit label definition.
Split 60% train, 20% validation, 20% test Learn, choose, then assess on separate rows.
Model selection Validation macro F1; log loss breaks ties Treat both classes equally and consider probability quality.
Default classification Positive if score is at least 0.5 A common comparison rule, locked before testing.
Example triage rule Tune a negative-score threshold on validation only Show the trade-off between missed negatives and false alerts.
Final metrics Accuracy, macro F1, negative precision/recall, ROC AUC, log loss, Brier score Different errors and probabilities need different measures.

The source benchmark is the Large Movie Review Dataset introduced by Maas et al. (2011). It contains strong positive/negative reviews rather than a neutral class. The supplied combined CSV has no original split, movie ID, author ID or date. Our new random split is therefore a local evaluation, not the official Stanford benchmark split. It cannot rule out shared films, authors or near-duplicate wording across partitions.

What changed from the original notebooks

Original issue Portfolio improvement
The test set was repeatedly used for early stopping, activation comparison and tuning. An independent validation set is used for those decisions.
An embedding + average-pooling + Dense(ReLU) model was labelled an RNN. It is correctly named a pooling network; SimpleRNN and LSTM are separate architectures.
Duplicates were not excluded before splitting. Audit raw and normalised duplicates, quarantine label conflicts, then deduplicate.
Task 1 learned a tokenizer before splitting and kept text target labels. Fit vocabulary on training only; encode targets explicitly.
Task 1 referenced missing imports/callbacks and an absent GloVe file. Self-contained core; optional neural section has explicit dependencies and needs no GloVe download.
Tuner output reloaded old trials, while code requested one trial. Use an explicit small validation search with recorded candidates; no hidden tuner cache.
Early stopping did not restore the best weights. Optional neural callbacks restore the best validation-loss weights.
Already balanced data was sampled down and comments disagreed with sample size. Use every valid unique review; report actual counts.
Long paragraphs repeated metrics and mixed progress-bar estimates with full-set scores. Calculate interpretations from the same stored predictions used for the metrics.
No majority or strong classical baseline. Include a majority baseline, Naive Bayes and tuned TF-IDF logistic regression.

Spark is useful when scale or a distributed platform justifies it. This 66 MB CSV can be handled directly in pandas, which keeps the teaching workflow simpler and avoids the original CSV-parsing/Java dependencies.

Setup

Use Python 3.12 and install requirements.txt from the package. Launch JupyterLab, place IMDB Dataset.csv beside this notebook, and choose Restart Kernel and Run All. Change only DATA_PATH if your data lives elsewhere. The package does not redistribute the review dataset; keep your supplied CSV or obtain it from the linked dataset source.

The default run is complete without TensorFlow. For the optional experiments, install requirements-neural.txt, set RUN_NEURAL = True, and run all cells again. Neural scores will then enter the same validation comparison before the final test section. Avoid choosing a new model after looking at test results; further experiments need a new holdout.

01. Import file and text utilities

These standard-library modules handle paths, normalisation and a reproducibility fingerprint.

STEP 01Executed core# Link
Python code
# Import file and text utilities
from pathlib import Path
import hashlib
import html
import json
import re
import unicodedata
import platform
import importlib.metadata
from time import perf_counter
Add a learning note Personal notes are saved in this browser.

02. Import data and plotting libraries

Keep imports grouped by their job. display is provided by Jupyter.

STEP 02Executed core# Link
Python code
# Import data and plotting libraries
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import joblib
Add a learning note Personal notes are saved in this browser.

03. Import feature and model tools

TF-IDF measures term importance. The two classifiers provide inexpensive, reproducible comparisons.

STEP 03Executed core# Link
Python code
# Import feature and model tools
from sklearn.model_selection import train_test_split
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.dummy import DummyClassifier
from sklearn.naive_bayes import MultinomialNB
from sklearn.linear_model import LogisticRegression
Add a learning note Personal notes are saved in this browser.

04. Import evaluation tools

Each metric answers a different question; accuracy alone can conceal costly errors.

STEP 04Executed core# Link
Python code
# Import evaluation tools
from sklearn.metrics import (
    accuracy_score, balanced_accuracy_score, f1_score,
    precision_score, recall_score, roc_auc_score,
    log_loss, brier_score_loss, confusion_matrix, classification_report,
    RocCurveDisplay, PrecisionRecallDisplay,
)
from sklearn.calibration import calibration_curve
Add a learning note Personal notes are saved in this browser.

05. Set the data and output paths

All output paths are relative to this project. Source data is never overwritten.

STEP 05Executed core# Link
Python code
# Set the data and output paths
DATA_PATH = Path('IMDB Dataset.csv')
OUTPUT_DIR = Path('artifacts')
OUTPUT_DIR.mkdir(exist_ok=True)
if not DATA_PATH.is_file():
    raise FileNotFoundError('Place IMDB Dataset.csv beside the notebook or edit DATA_PATH.')
Add a learning note Personal notes are saved in this browser.

06. Set reproducible experiment choices

These choices are fixed before examining the test set. Keep RUN_NEURAL = False for the tested core.

STEP 06Executed core# Link
Python code
# Set reproducible experiment choices
RANDOM_STATE = 42
TFIDF_MAX_FEATURES = 50_000
LOGISTIC_C_VALUES = [0.25, 1.0, 4.0]
BOOTSTRAP_REPEATS = 1_000
RUN_NEURAL = False
MAX_LENGTH = 256
NEURAL_VOCAB_SIZE = 15_000
NEURAL_EPOCHS = 10
NEURAL_BATCH_SIZE = 128
np.random.seed(RANDOM_STATE)
Add a learning note Personal notes are saved in this browser.

07. Set a readable visual style

Charts use Gareth’s navy and scarlet palette with labels that explain the measure.

STEP 07Executed core# Link
Python code
# Set a readable visual style
NAVY, SCARLET, SLATE = '#16324F', '#FF2400', '#667582'
plt.rcParams.update({
    'figure.figsize': (8, 4), 'figure.dpi': 120,
    'axes.spines.top': False, 'axes.spines.right': False,
    'axes.titleweight': 'bold', 'axes.labelcolor': NAVY,
    'text.color': NAVY, 'font.size': 10,
})
pd.set_option('display.max_colwidth', 180)
Add a learning note Personal notes are saved in this browser.

08. Record the source fingerprint

A SHA-256 hash identifies the exact file used; a matching filename alone is insufficient.

STEP 08Executed core# Link
Python code
# Record the source fingerprint
source_sha256 = hashlib.sha256(DATA_PATH.read_bytes()).hexdigest()
print('Dataset SHA-256:', source_sha256)
print(f'Dataset size: {DATA_PATH.stat().st_size / 1_000_000:.2f} MB')
Dataset SHA-256: dfc447764f82be365fa9c2beef4e8df89d3919e3da95f5088004797d79695aa2
Dataset size: 66.21 MB
Add a learning note Personal notes are saved in this browser.

Data audit and cleaning

09. Read the CSV

The CSV parser respects quoted text containing commas. Load once and preserve a source-row index.

STEP 09Executed core# Link
Python code
# Read the CSV
raw = pd.read_csv(DATA_PATH, dtype={'review': 'string', 'sentiment': 'string'})
raw.index.name = 'source_row'
print(f'Loaded {len(raw):,} rows and {raw.shape[1]} columns.')
Loaded 50,000 rows and 2 columns.
Add a learning note Personal notes are saved in this browser.

10. Check the required schema

Stop on missing fields or missing values instead of silently treating invalid labels as negative.

STEP 10Executed core# Link
Python code
# Check the required schema
required_columns = {'review', 'sentiment'}
if not required_columns.issubset(raw.columns):
    raise ValueError(f'Required columns: {sorted(required_columns)}')
if raw[list(required_columns)].isna().any().any():
    raise ValueError('Review or sentiment is missing; investigate before modelling.')
display(raw.dtypes.rename('dtype').to_frame())
dtype
review string[python]
sentiment string[python]
Add a learning note Personal notes are saved in this browser.

11. Validate the target labels

An unknown label is a data-quality issue. It must not automatically become class zero.

STEP 11Executed core# Link
Python code
# Validate the target labels
raw['sentiment'] = raw['sentiment'].str.strip().str.lower()
unexpected_labels = set(raw['sentiment'].unique()) - {'negative', 'positive'}
if unexpected_labels:
    raise ValueError(f'Unexpected sentiment labels: {unexpected_labels}')
display(raw['sentiment'].value_counts().rename('reviews').to_frame())
reviews
sentiment
positive 25000
negative 25000
Add a learning note Personal notes are saved in this browser.

12. Audit raw duplicates

Record duplicates before changing the text, so the cleaning effect remains visible.

STEP 12Executed core# Link
Python code
# Audit raw duplicates
raw_duplicate_rows = int(raw.duplicated(['review', 'sentiment']).sum())
raw_duplicate_texts = int(raw.duplicated('review').sum())
raw_conflicts = int(raw.groupby('review')['sentiment'].nunique().gt(1).sum())
print(f'Exact duplicate rows: {raw_duplicate_rows:,}')
print(f'Repeated raw reviews: {raw_duplicate_texts:,}; conflicting raw texts: {raw_conflicts:,}')
Exact duplicate rows: 418
Repeated raw reviews: 418; conflicting raw texts: 0
Add a learning note Personal notes are saved in this browser.

13. Define minimal text normalisation

Remove HTML and formatting noise. Retain negation, apostrophes and numbers; do not delete stopwords such as “not”. This deterministic function does not learn from labels.

STEP 13Executed core# Link
Python code
# Define minimal text normalisation
def normalise_text(text):
    text = unicodedata.normalize('NFKC', html.unescape(str(text)))
    text = text.replace('’', "'").lower()
    text = re.sub(r'<[^>]*>', ' ', text)
    text = re.sub(r"[^a-z0-9'\s]", ' ', text)
    return re.sub(r'\s+', ' ', text).strip()
Add a learning note Personal notes are saved in this browser.

14. Build the normalised text column

Keep the original text available for audit; models use only the new text column.

STEP 14Executed core# Link
Python code
# Build the normalised text column
data = raw.reset_index().copy()
data['text'] = data['review'].map(normalise_text)
print('Example:', data['text'].iloc[0][:180] + '…')
Example: one of the other reviewers has mentioned that after watching just 1 oz episode you'll be hooked they are right as this is exactly what happened with me the first thing that struck …
Add a learning note Personal notes are saved in this browser.

15. Find empty and conflicting normalised reviews

A conflict means identical model input has different targets. Quarantine every row in that group rather than choosing a convenient label.

STEP 15Executed core# Link
Python code
# Find empty and conflicting normalised reviews
empty_mask = data['text'].eq('')
conflict_texts = data.groupby('text')['sentiment'].nunique()
conflict_texts = conflict_texts[conflict_texts > 1].index
conflict_mask = data['text'].isin(conflict_texts)
normalised_duplicate_rows = int(data.duplicated('text').sum())
print(f'Empty texts: {empty_mask.sum():,}')
print(f'Conflicting text groups: {len(conflict_texts):,}; affected rows: {conflict_mask.sum():,}')
print(f'Repeated normalised texts: {normalised_duplicate_rows:,}')
Empty texts: 0
Conflicting text groups: 0; affected rows: 0
Repeated normalised texts: 423
Add a learning note Personal notes are saved in this browser.

16. Quarantine invalid text rows

Counts stay separate from the duplicate-removal count so every excluded row can be reconciled.

STEP 16Executed core# Link
Python code
# Quarantine invalid text rows
quarantine_mask = empty_mask | conflict_mask
quarantined_rows = int(quarantine_mask.sum())
eligible = data.loc[~quarantine_mask].copy()
print(f'Quarantined rows: {quarantined_rows:,}')
Quarantined rows: 0
Add a learning note Personal notes are saved in this browser.

17. Remove repeated model inputs

Keep the first occurrence of each eligible normalised text before making any split.

STEP 17Executed core# Link
Python code
# Remove repeated model inputs
duplicates_removed = int(eligible.duplicated('text').sum())
clean = eligible.drop_duplicates('text', keep='first').copy()
clean = clean.reset_index(drop=True)
assert len(raw) == quarantined_rows + duplicates_removed + len(clean)
print(f'Removed {duplicates_removed:,} repeated reviews; retained {len(clean):,} unique reviews.')
Removed 423 repeated reviews; retained 49,577 unique reviews.
Add a learning note Personal notes are saved in this browser.

18. Encode the target

Use explicit integer targets: negative is zero; positive is one.

STEP 18Executed core# Link
Python code
# Encode the target
label_map = {'negative': 0, 'positive': 1}
clean['label'] = clean['sentiment'].map(label_map).astype('int8')
display(clean['sentiment'].value_counts().rename('unique_reviews').to_frame())
unique_reviews
sentiment
positive 24881
negative 24696
Add a learning note Personal notes are saved in this browser.

19. Assign a stable review identifier

Hash the normalised input to identify rows in exported splits without publishing their full text. These hashes are identifiers, not anonymisation for sensitive data.

STEP 19Executed core# Link
Python code
# Assign a stable review identifier
clean['review_id'] = clean['text'].map(lambda text: hashlib.sha256(text.encode()).hexdigest())
assert clean['review_id'].is_unique
print('All retained review IDs are unique.')
All retained review IDs are unique.
Add a learning note Personal notes are saved in this browser.

Interpretation: the original CSV is already balanced, so artificial resampling is not needed. Deduplication may produce a small imbalance, which stratification preserves. Normalisation also exposes reviews differing only in formatting. Near duplicates are not detected by this exact-match audit and remain a limitation.

Split before learning

The validation set is used to choose settings and the review threshold. The test set stays sealed until both decisions are recorded. Deterministic cleaning and duplicate auditing precede the split; vocabulary, IDF weights and model parameters do not.

20. Reserve the final test set

Stratification preserves the label proportions. Reserve 20% of unique reviews.

STEP 20Executed core# Link
Python code
# Reserve the final test set
development_df, test_df = train_test_split(
    clean, test_size=0.20, random_state=RANDOM_STATE, stratify=clean['label'],
)
Add a learning note Personal notes are saved in this browser.

21. Separate training and validation

One quarter of the remaining 80% is 20% of the complete cleaned dataset.

STEP 21Executed core# Link
Python code
# Separate training and validation
train_df, val_df = train_test_split(
    development_df, test_size=0.25, random_state=RANDOM_STATE,
    stratify=development_df['label'],
)
train_df, val_df, test_df = [frame.reset_index(drop=True) for frame in (train_df, val_df, test_df)]
Add a learning note Personal notes are saved in this browser.

22. Verify partition independence

These checks catch accidental row reuse and cross-partition copies of normalised reviews.

STEP 22Executed core# Link
Python code
# Verify partition independence
train_ids, val_ids, test_ids = [set(frame['review_id']) for frame in (train_df, val_df, test_df)]
assert train_ids.isdisjoint(val_ids)
assert train_ids.isdisjoint(test_ids)
assert val_ids.isdisjoint(test_ids)
assert len(train_df) + len(val_df) + len(test_df) == len(clean)
print('No normalised review overlaps train, validation or test.')
No normalised review overlaps train, validation or test.
Add a learning note Personal notes are saved in this browser.

23. Report the development split

Only training and validation class distributions are inspected before the final evaluation.

STEP 23Executed core# Link
Python code
# Report the development split
development_summary = pd.DataFrame({
    'rows': [len(train_df), len(val_df)],
    'negative': [train_df['label'].eq(0).sum(), val_df['label'].eq(0).sum()],
    'positive': [train_df['label'].eq(1).sum(), val_df['label'].eq(1).sum()],
}, index=['training', 'validation'])
display(development_summary)
print(f'Reserved test rows: {len(test_df):,}; not used for feature fitting or selection.')
rows negative positive
training 29745 14817 14928
validation 9916 4940 4976
Reserved test rows: 9,916; not used for feature fitting or selection.
Add a learning note Personal notes are saved in this browser.

24. Create the target arrays

The arrays use the same row order as their DataFrames.

STEP 24Executed core# Link
Python code
# Create the target arrays
y_train = train_df['label'].to_numpy()
y_val = val_df['label'].to_numpy()
y_test = test_df['label'].to_numpy()
Add a learning note Personal notes are saved in this browser.

Explore the training data

25. Count training review lengths

Length uses whitespace tokens after cleaning. These are descriptive counts, not the TF-IDF vocabulary.

STEP 25Executed core# Link
Python code
# Count training review lengths
train_word_count = train_df['text'].str.split().str.len()
display(train_word_count.describe(percentiles=[0.50, 0.90, 0.95]).round(1).to_frame('words'))
words
count 29745.0
mean 231.2
std 170.8
min 9.0
50% 174.0
90% 448.6
95% 586.0
max 2473.0
Add a learning note Personal notes are saved in this browser.

26. Plot the training class balance

Count bars show the actual data used to fit the models.

STEP 26Executed core# Link
Python code
# Plot the training class balance
fig, ax = plt.subplots(figsize=(6, 3.5))
counts = train_df['sentiment'].value_counts().reindex(['negative', 'positive'])
ax.bar(counts.index, counts.values, color=[SCARLET, NAVY])
ax.set(title='Training reviews by sentiment', ylabel='Number of unique reviews')
ax.bar_label(ax.containers[0], fmt='%d', padding=3)
fig.tight_layout()
plt.show()
Plot the training class balance
Add a learning note Personal notes are saved in this browser.

27. Plot the review length distribution

Long documents matter because optional sequence models truncate them. Classical models use the entire normalised review.

STEP 27Executed core# Link
Python code
# Plot the review length distribution
fig, ax = plt.subplots()
ax.hist(train_word_count, bins=60, color=NAVY)
ax.axvline(MAX_LENGTH, color=SCARLET, label=f'Neural limit: {MAX_LENGTH} words')
ax.set(xlim=(0, 1_000), xlabel='Words after cleaning', ylabel='Training reviews',
       title='Training review lengths (view limited to 1,000 words)')
ax.legend()
fig.tight_layout()
plt.show()
Plot the review length distribution
Add a learning note Personal notes are saved in this browser.

28. Measure the planned truncation rate

Quantify the compromise rather than assuming 100 or 256 words preserve the whole review.

STEP 28Executed core# Link
Python code
# Measure the planned truncation rate
truncation_rate = float(train_word_count.gt(MAX_LENGTH).mean())
print(f'{truncation_rate:.1%} of training reviews exceed the optional {MAX_LENGTH}-word limit.')
print('Sequence models would discard later words; TF-IDF does not impose this length limit.')
28.6% of training reviews exceed the optional 256-word limit.
Sequence models would discard later words; TF-IDF does not impose this length limit.
Add a learning note Personal notes are saved in this browser.

Turn text into features

Term frequency measures how strongly a term appears in a review. Inverse document frequency reduces the influence of terms appearing in many training reviews. A bigram is a two-word phrase such as “not good”, so the feature representation can capture some local word order. It cannot understand the complete sentence or sarcasm.

29. Define the TF-IDF representation

Set all preprocessing choices explicitly. Retaining common words helps preserve negation.

STEP 29Executed core# Link
Python code
# Define the TF-IDF representation
vectorizer = TfidfVectorizer(
    max_features=TFIDF_MAX_FEATURES, ngram_range=(1, 2), min_df=2,
    sublinear_tf=True, lowercase=False, stop_words=None,
    token_pattern=r"(?u)\b\w[\w']+\b", dtype=np.float32,
)
Add a learning note Personal notes are saved in this browser.

30. Fit features on training reviews only

Only this cell learns the vocabulary and IDF weights. Fitting on validation or test text would leak information.

STEP 30Executed core# Link
Python code
# Fit features on training reviews only
feature_start = perf_counter()
X_train = vectorizer.fit_transform(train_df['text'])
feature_fit_seconds = perf_counter() - feature_start
print(f'Training matrix: {X_train.shape}; nonzero entries: {X_train.nnz:,}')
print(f'Feature fitting took {feature_fit_seconds:.2f} seconds on this machine.')
Training matrix: (29745, 50000); nonzero entries: 7,093,508
Feature fitting took 21.88 seconds on this machine.
Add a learning note Personal notes are saved in this browser.

31. Transform validation reviews

Use the training vocabulary without calling fit again. The test matrix is created later.

STEP 31Executed core# Link
Python code
# Transform validation reviews
X_val = vectorizer.transform(val_df['text'])
assert X_train.shape[1] == X_val.shape[1]
print('Validation matrix:', X_val.shape)
Validation matrix: (9916, 50000)
Add a learning note Personal notes are saved in this browser.

32. Inspect a few vocabulary features

These are learned features, not rankings of causally important words.

STEP 32Executed core# Link
Python code
# Inspect a few vocabulary features
feature_names = vectorizer.get_feature_names_out()
display(pd.DataFrame({'example_feature': feature_names[:15]}))
example_feature
0 00
1 000
2 000 000
3 000 to
4 007
5 01
6 02
7 10
8 10 000
9 10 10
10 10 and
11 10 as
12 10 because
13 10 but
14 10 for
Add a learning note Personal notes are saved in this browser.

Classical models

The majority baseline ignores the review. Naive Bayes is a fast probabilistic text classifier with a simplifying feature-independence assumption. Logistic regression learns weighted evidence for the two classes and is often a useful interpretable text baseline. Here only its regularisation strength is tuned, over three declared candidates.

Metric guide: accuracy is the fraction correct; macro F1 gives each class equal weight; negative recall measures how many negative reviews are caught; negative precision measures how many flagged reviews are actually negative. ROC AUC measures ranking, while log loss and Brier score assess probability quality. Lower loss/Brier is better.

33. Define one consistent metric summary

Every comparison uses the same label convention and 0.5 decision rule.

STEP 33Executed core# Link
Python code
# Define one consistent metric summary
def metric_summary(name, truth, positive_score):
    prediction = (np.asarray(positive_score) >= 0.5).astype(int)
    return {
        'model': name,
        'accuracy': accuracy_score(truth, prediction),
        'balanced_accuracy': balanced_accuracy_score(truth, prediction),
        'macro_f1': f1_score(truth, prediction, average='macro', zero_division=0),
        'negative_precision': precision_score(truth, prediction, pos_label=0, zero_division=0),
        'negative_recall': recall_score(truth, prediction, pos_label=0, zero_division=0),
        'roc_auc': roc_auc_score(truth, positive_score),
        'log_loss': log_loss(truth, positive_score, labels=[0, 1]),
        'brier_score': brier_score_loss(truth, positive_score),
    }
Add a learning note Personal notes are saved in this browser.

34. Fit the majority baseline

A complex model must earn an improvement over predicting the training majority class.

STEP 34Executed core# Link
Python code
# Fit the majority baseline
majority_model = DummyClassifier(strategy='prior', random_state=RANDOM_STATE)
majority_model.fit(X_train, y_train)
majority_val_score = majority_model.predict_proba(X_val)[:, 1]
display(pd.DataFrame([metric_summary('Majority baseline', y_val, majority_val_score)]).round(4))
model accuracy balanced_accuracy macro_f1 negative_precision negative_recall roc_auc log_loss brier_score
0 Majority baseline 0.5018 0.5 0.3341 0.0 0.0 0.5 0.6931 0.25
Add a learning note Personal notes are saved in this browser.

35. Fit the Naive Bayes baseline

The classifier receives the same training TF-IDF features as logistic regression.

STEP 35Executed core# Link
Python code
# Fit the Naive Bayes baseline
start = perf_counter()
nb_model = MultinomialNB(alpha=1.0).fit(X_train, y_train)
nb_fit_seconds = perf_counter() - start
nb_val_score = nb_model.predict_proba(X_val)[:, 1]
display(pd.DataFrame([metric_summary('TF-IDF + Naive Bayes', y_val, nb_val_score)]).round(4))
model accuracy balanced_accuracy macro_f1 negative_precision negative_recall roc_auc log_loss brier_score
0 TF-IDF + Naive Bayes 0.8858 0.8858 0.8858 0.8861 0.8846 0.9541 0.3192 0.0938
Add a learning note Personal notes are saved in this browser.

36. Fit the declared logistic candidates

Smaller C means stronger regularisation. Evaluate candidates on validation only.

STEP 36Executed core# Link
Python code
# Fit the declared logistic candidates
logistic_models, tuning_rows = {}, []
for c_value in LOGISTIC_C_VALUES:
    candidate = LogisticRegression(C=c_value, solver='liblinear', max_iter=300,
                                   random_state=RANDOM_STATE)
    start = perf_counter()
    candidate.fit(X_train, y_train)
    score = candidate.predict_proba(X_val)[:, 1]
    row = metric_summary(f'C={c_value}', y_val, score)
    row.update(C=c_value, fit_seconds=perf_counter() - start)
    logistic_models[c_value] = candidate
    tuning_rows.append(row)
tuning_results = pd.DataFrame(tuning_rows)
Add a learning note Personal notes are saved in this browser.

37. Choose logistic regularisation on validation

Select by macro F1, then lower log loss, then smaller C. No test score participates.

STEP 37Executed core# Link
Python code
# Choose logistic regularisation on validation
tuning_results = tuning_results.sort_values(['macro_f1', 'log_loss', 'C'],
                                            ascending=[False, True, True])
best_c = float(tuning_results.iloc[0]['C'])
logistic_model = logistic_models[best_c]
logistic_val_score = logistic_model.predict_proba(X_val)[:, 1]
display(tuning_results.round(4))
print(f'Locked logistic C: {best_c:g}')
model accuracy balanced_accuracy macro_f1 negative_precision negative_recall roc_auc log_loss brier_score C fit_seconds
2 C=4.0 0.9103 0.9103 0.9103 0.9160 0.9028 0.9713 0.2363 0.0686 4.00 2.7200
1 C=1.0 0.9049 0.9049 0.9049 0.9113 0.8964 0.9682 0.2898 0.0821 1.00 1.5248
0 C=0.25 0.8902 0.8901 0.8902 0.8992 0.8779 0.9594 0.3866 0.1131 0.25 2.0093
Locked logistic C: 4
Add a learning note Personal notes are saved in this browser.

38. Register the classical comparison models

Keep model objects and validation predictions separate from the held-out test results.

STEP 38Executed core# Link
Python code
# Register the classical comparison models
core_models = {
    'Majority baseline': majority_model,
    'TF-IDF + Naive Bayes': nb_model,
    'TF-IDF + Logistic Regression': logistic_model,
}
validation_scores = {
    name: model.predict_proba(X_val)[:, 1] for name, model in core_models.items()
}
neural_models, neural_histories = {}, {}
Add a learning note Personal notes are saved in this browser.

Optional neural experiments

This section preserves the deep-learning learning goals of the original work, with correct architecture names and training-only vocabulary. It is optional and unexecuted in the supplied results. The guards keep the tested core runnable without TensorFlow. Setting RUN_NEURAL = True requires the neural dependencies and a fresh complete run.

Architecture What it learns Important limitation
Embedding + average pooling A learned average of token embeddings Averaging discards sequence order. ReLU does not make it recurrent.
Embedding + SimpleRNN A hidden state updated in token order Long dependencies can be difficult to learn.
Embedding + LSTM A recurrent state with gates controlling memory More parameters and compute; superiority must be measured.

All three use the same data, vocabulary, length cap, embedding size, epoch limit, batch size and early-stopping rule. Their parameter counts and capacity still differ. This is a small teaching comparison, not an exhaustive architecture search. See the official TensorFlow RNN tutorial.

An embedding with mask_zero=True prevents padding from being treated as a real word. Each training call receives a fresh early-stopping callback with restore_best_weights=True. Hyperparameter changes must use validation results; the test section remains downstream.

39. Report whether neural training is enabled

A disabled experiment has no score. It must never appear as a trained result.

STEP 39Executed core# Link
Python code
# Report whether neural training is enabled
tf = None
if RUN_NEURAL:
    import tensorflow as tf
    tf.keras.utils.set_random_seed(RANDOM_STATE)
    print('TensorFlow version:', tf.__version__)
else:
    print('Neural experiments: NOT RUN. Core comparisons below are fully executed.')
Neural experiments: NOT RUN. Core comparisons below are fully executed.
Add a learning note Personal notes are saved in this browser.

40. Create the neural text vectorizer

Token zero is padding and token one represents an unknown word. Normalisation was already applied, so no second standardisation is needed.

STEP 40Optional · unexecuted# Link
Python code
# Create the neural text vectorizer
if RUN_NEURAL:
    neural_vectorizer = tf.keras.layers.TextVectorization(
        max_tokens=NEURAL_VOCAB_SIZE, standardize=None,
        output_mode='int', output_sequence_length=MAX_LENGTH,
    )
Add a learning note Personal notes are saved in this browser.

41. Learn the neural vocabulary from training only

Adapt on training reviews; never adapt on validation or test reviews.

STEP 41Optional · unexecuted# Link
Python code
# Learn the neural vocabulary from training only
if RUN_NEURAL:
    vocabulary_data = tf.data.Dataset.from_tensor_slices(train_df['text'].to_numpy()).batch(256)
    neural_vectorizer.adapt(vocabulary_data)
    neural_vocab_size = len(neural_vectorizer.get_vocabulary())
    print('Neural vocabulary size, including special tokens:', neural_vocab_size)
Add a learning note Personal notes are saved in this browser.

42. Encode the training sequences

The fixed length uses padding and truncation. All neural models receive the same integer sequences.

STEP 42Optional · unexecuted# Link
Python code
# Encode the training sequences
if RUN_NEURAL:
    train_tokens = neural_vectorizer(train_df['text'].to_numpy()).numpy()
    print('Training token shape:', train_tokens.shape)
Add a learning note Personal notes are saved in this browser.

43. Encode the validation sequences

Use the existing vocabulary without updating it. Do not encode the test set yet.

STEP 43Optional · unexecuted# Link
Python code
# Encode the validation sequences
if RUN_NEURAL:
    val_tokens = neural_vectorizer(val_df['text'].to_numpy()).numpy()
    print('Validation token shape:', val_tokens.shape)
Add a learning note Personal notes are saved in this browser.

44. Prepare the neural training batches

Shuffle training rows with a fixed seed; validation rows keep their order.

STEP 44Optional · unexecuted# Link
Python code
# Prepare the neural training batches
if RUN_NEURAL:
    neural_train_data = tf.data.Dataset.from_tensor_slices((train_tokens, y_train.astype('float32')))
    neural_train_data = neural_train_data.shuffle(len(y_train), seed=RANDOM_STATE)
    neural_train_data = neural_train_data.batch(NEURAL_BATCH_SIZE).prefetch(tf.data.AUTOTUNE)
    neural_val_data = tf.data.Dataset.from_tensor_slices((val_tokens, y_val.astype('float32')))
    neural_val_data = neural_val_data.batch(NEURAL_BATCH_SIZE).prefetch(tf.data.AUTOTUNE)
Add a learning note Personal notes are saved in this browser.

45. Define a fresh neural stopping rule

Monitor validation loss and restore the best epoch. Do not reuse callback state across models.

STEP 45Optional · unexecuted# Link
Python code
# Define a fresh neural stopping rule
if RUN_NEURAL:
    def neural_callbacks():
        return [tf.keras.callbacks.EarlyStopping(
            monitor='val_loss', patience=2, restore_best_weights=True,
        )]
Add a learning note Personal notes are saved in this browser.

46. Build the average-pooling network

This is the correctly named version of the original “ReLU RNN”. Its embedding average has no recurrent state.

STEP 46Optional · unexecuted# Link
Python code
# Build the average-pooling network
if RUN_NEURAL:
    tf.keras.utils.set_random_seed(RANDOM_STATE)
    pool_model = tf.keras.Sequential([
        tf.keras.Input(shape=(MAX_LENGTH,), dtype='int32'),
        tf.keras.layers.Embedding(neural_vocab_size, 32, mask_zero=True),
        tf.keras.layers.GlobalAveragePooling1D(),
        tf.keras.layers.Dense(16, activation='relu'),
        tf.keras.layers.Dropout(0.2),
        tf.keras.layers.Dense(1, activation='sigmoid'),
    ], name='embedding_average_pooling')
    pool_model.summary()
Add a learning note Personal notes are saved in this browser.

47. Compile the pooling network

Binary cross-entropy matches the zero/one target. Gradient clipping constrains unusually large updates.

STEP 47Optional · unexecuted# Link
Python code
# Compile the pooling network
if RUN_NEURAL:
    pool_model.compile(optimizer=tf.keras.optimizers.Adam(0.001, clipnorm=1.0),
                       loss='binary_crossentropy', metrics=['accuracy'])
Add a learning note Personal notes are saved in this browser.

48. Train the pooling network

Only training data updates weights; validation loss controls stopping.

STEP 48Optional · unexecuted# Link
Python code
# Train the pooling network
if RUN_NEURAL:
    pool_history = pool_model.fit(neural_train_data, validation_data=neural_val_data,
                                  epochs=NEURAL_EPOCHS, callbacks=neural_callbacks(), verbose=2)
    neural_models['Embedding + average pooling'] = pool_model
    neural_histories['Embedding + average pooling'] = pool_history.history
Add a learning note Personal notes are saved in this browser.

49. Build the SimpleRNN network

SimpleRNN is a genuine recurrent layer. Here tanh is the state activation, not a separate architecture name.

STEP 49Optional · unexecuted# Link
Python code
# Build the SimpleRNN network
if RUN_NEURAL:
    tf.keras.utils.set_random_seed(RANDOM_STATE)
    rnn_model = tf.keras.Sequential([
        tf.keras.Input(shape=(MAX_LENGTH,), dtype='int32'),
        tf.keras.layers.Embedding(neural_vocab_size, 32, mask_zero=True),
        tf.keras.layers.SimpleRNN(32, activation='tanh'),
        tf.keras.layers.Dropout(0.2),
        tf.keras.layers.Dense(1, activation='sigmoid'),
    ], name='simple_rnn')
    rnn_model.summary()
Add a learning note Personal notes are saved in this browser.

50. Compile the SimpleRNN network

Use the same loss and optimisation choices as the other neural models.

STEP 50Optional · unexecuted# Link
Python code
# Compile the SimpleRNN network
if RUN_NEURAL:
    rnn_model.compile(optimizer=tf.keras.optimizers.Adam(0.001, clipnorm=1.0),
                      loss='binary_crossentropy', metrics=['accuracy'])
Add a learning note Personal notes are saved in this browser.

51. Train the SimpleRNN network

The callback is created afresh for this run.

STEP 51Optional · unexecuted# Link
Python code
# Train the SimpleRNN network
if RUN_NEURAL:
    rnn_history = rnn_model.fit(neural_train_data, validation_data=neural_val_data,
                                epochs=NEURAL_EPOCHS, callbacks=neural_callbacks(), verbose=2)
    neural_models['Embedding + SimpleRNN'] = rnn_model
    neural_histories['Embedding + SimpleRNN'] = rnn_history.history
Add a learning note Personal notes are saved in this browser.

52. Build the LSTM network

LSTM adds memory gates. A more complex model must still outperform simpler baselines.

STEP 52Optional · unexecuted# Link
Python code
# Build the LSTM network
if RUN_NEURAL:
    tf.keras.utils.set_random_seed(RANDOM_STATE)
    lstm_model = tf.keras.Sequential([
        tf.keras.Input(shape=(MAX_LENGTH,), dtype='int32'),
        tf.keras.layers.Embedding(neural_vocab_size, 32, mask_zero=True),
        tf.keras.layers.LSTM(32),
        tf.keras.layers.Dropout(0.2),
        tf.keras.layers.Dense(1, activation='sigmoid'),
    ], name='lstm')
    lstm_model.summary()
Add a learning note Personal notes are saved in this browser.

53. Compile the LSTM network

Keep the training objective consistent with the comparison models.

STEP 53Optional · unexecuted# Link
Python code
# Compile the LSTM network
if RUN_NEURAL:
    lstm_model.compile(optimizer=tf.keras.optimizers.Adam(0.001, clipnorm=1.0),
                       loss='binary_crossentropy', metrics=['accuracy'])
Add a learning note Personal notes are saved in this browser.

54. Train the LSTM network

Restore the best validation-loss weights rather than evaluating the final deteriorated epoch.

STEP 54Optional · unexecuted# Link
Python code
# Train the LSTM network
if RUN_NEURAL:
    lstm_history = lstm_model.fit(neural_train_data, validation_data=neural_val_data,
                                  epochs=NEURAL_EPOCHS, callbacks=neural_callbacks(), verbose=2)
    neural_models['Embedding + LSTM'] = lstm_model
    neural_histories['Embedding + LSTM'] = lstm_history.history
Add a learning note Personal notes are saved in this browser.

55. Compare neural training curves

Falling training loss alongside rising validation loss suggests overfitting. No curves are fabricated when this section is disabled.

STEP 55Optional · unexecuted# Link
Python code
# Compare neural training curves
if RUN_NEURAL:
    fig, ax = plt.subplots()
    for name, history in neural_histories.items():
        epochs = np.arange(1, len(history['loss']) + 1)
        ax.plot(epochs, history['val_loss'], label=name)
    ax.set(xlabel='Epoch', ylabel='Validation binary cross-entropy',
           title='Neural validation learning curves')
    ax.legend()
    fig.tight_layout()
    plt.show()
Add a learning note Personal notes are saved in this browser.

56. Register neural validation predictions

Neural models enter selection only after actual training. Flatten output arrays before metric calculations.

STEP 56Optional · unexecuted# Link
Python code
# Register neural validation predictions
if RUN_NEURAL:
    for name, model in neural_models.items():
        validation_scores[name] = model.predict(val_tokens, verbose=0).ravel()
Add a learning note Personal notes are saved in this browser.

Lock the model and operating rule

The comparison model is chosen on validation macro F1. Separately, an illustrative negative-feedback queue uses a cost rule: a missed negative costs five units; a false alert costs one unit. These units are declared assumptions, not measured rand, customer losses or benefits. A real organisation must estimate its own error costs and review capacity.

negative_score = 1 - positive_score. Flag a review when its negative score reaches a chosen threshold. Lower thresholds usually catch more negatives and create more alerts. The scores are model estimates; they are not established calibrated probabilities.

57. Compare all executed models on validation

A model that was not trained is absent from this score table.

STEP 57Executed core# Link
Python code
# Compare all executed models on validation
validation_results = pd.DataFrame([
    metric_summary(name, y_val, score) for name, score in validation_scores.items()
]).sort_values(['macro_f1', 'log_loss', 'model'], ascending=[False, True, True])
display(validation_results.round(4))
model accuracy balanced_accuracy macro_f1 negative_precision negative_recall roc_auc log_loss brier_score
2 TF-IDF + Logistic Regression 0.9103 0.9103 0.9103 0.9160 0.9028 0.9713 0.2363 0.0686
1 TF-IDF + Naive Bayes 0.8858 0.8858 0.8858 0.8861 0.8846 0.9541 0.3192 0.0938
0 Majority baseline 0.5018 0.5000 0.3341 0.0000 0.0000 0.5000 0.6931 0.2500
Add a learning note Personal notes are saved in this browser.

58. Lock the selected model

Do not change this choice after seeing the test results. Keep the strongest core model available for explanations and export.

STEP 58Executed core# Link
Python code
# Lock the selected model
selected_name = str(validation_results.iloc[0]['model'])
core_ranking = validation_results[validation_results['model'].isin(core_models)]
best_core_name = str(core_ranking.iloc[0]['model'])
best_core_model = core_models[best_core_name]
print('Model selected on validation:', selected_name)
print('Best classical model:', best_core_name)
Model selected on validation: TF-IDF + Logistic Regression
Best classical model: TF-IDF + Logistic Regression
Add a learning note Personal notes are saved in this browser.

59. Define the negative-feedback cost calculation

The names refer to a negative-review queue: a false alert is a positive review sent for negative-feedback review.

STEP 59Executed core# Link
Python code
# Define the negative-feedback cost calculation
def triage_summary(truth, positive_score, threshold):
    is_negative = np.asarray(truth) == 0
    flagged = (1 - np.asarray(positive_score)) >= threshold
    caught = int((is_negative & flagged).sum())
    missed = int((is_negative & ~flagged).sum())
    false_alerts = int((~is_negative & flagged).sum())
    return {
        'threshold': float(threshold), 'flagged': int(flagged.sum()),
        'negative_recall': caught / max(int(is_negative.sum()), 1),
        'negative_precision': caught / max(int(flagged.sum()), 1),
        'missed_negatives': missed, 'false_alerts': false_alerts,
        'relative_cost_units': 5 * missed + false_alerts,
    }
Add a learning note Personal notes are saved in this browser.

60. Evaluate declared thresholds on validation

The threshold grid is specified before testing. No optimisation occurs on the test labels.

STEP 60Executed core# Link
Python code
# Evaluate declared thresholds on validation
threshold_grid = np.round(np.arange(0.10, 0.91, 0.05), 2)
threshold_results = pd.DataFrame([
    triage_summary(y_val, validation_scores[selected_name], threshold)
    for threshold in threshold_grid
])
display(threshold_results.round(4))
threshold flagged negative_recall negative_precision missed_negatives false_alerts relative_cost_units
0 0.10 7290 0.9947 0.6741 26 2376 2506
1 0.15 6700 0.9899 0.7299 50 1810 2060
2 0.20 6276 0.9822 0.7731 88 1424 1864
3 0.25 5934 0.9739 0.8108 129 1123 1768
4 0.30 5689 0.9634 0.8365 181 930 1835
5 0.35 5484 0.9524 0.8580 235 779 1954
6 0.40 5264 0.9370 0.8794 311 635 2190
7 0.45 5064 0.9211 0.8985 390 514 2464
8 0.50 4869 0.9028 0.9160 480 409 2809
9 0.55 4686 0.8832 0.9311 577 323 3208
10 0.60 4498 0.8583 0.9426 700 258 3758
11 0.65 4314 0.8328 0.9536 826 200 4330
12 0.70 4098 0.8002 0.9646 987 145 5080
13 0.75 3871 0.7609 0.9711 1181 112 6017
14 0.80 3592 0.7109 0.9777 1428 80 7220
15 0.85 3216 0.6415 0.9854 1771 47 8902
16 0.90 2728 0.5484 0.9930 2231 19 11174
Add a learning note Personal notes are saved in this browser.

61. Lock the illustrative triage threshold

If costs tie, prefer the value nearest 0.5. This policy is independent of the default 0.5 classification comparison.

STEP 61Executed core# Link
Python code
# Lock the illustrative triage threshold
threshold_results['distance_from_half'] = (threshold_results['threshold'] - 0.5).abs()
threshold_choice = threshold_results.sort_values(
    ['relative_cost_units', 'distance_from_half', 'threshold']
).iloc[0]
triage_threshold = float(threshold_choice['threshold'])
locked_plan = {'selected_model': selected_name, 'logistic_C': best_c,
               'classification_threshold_positive': 0.5,
               'triage_threshold_negative': triage_threshold,
               'missed_negative_cost_units': 5, 'false_alert_cost_units': 1}
print(json.dumps(locked_plan, indent=2))
{
  "selected_model": "TF-IDF + Logistic Regression",
  "logistic_C": 4.0,
  "classification_threshold_positive": 0.5,
  "triage_threshold_negative": 0.25,
  "missed_negative_cost_units": 5,
  "false_alert_cost_units": 1
}
Add a learning note Personal notes are saved in this browser.

62. Plot the validation threshold trade-off

The chart makes review workload and missed-negative cost visible. It is not a return-on-investment result.

STEP 62Executed core# Link
Python code
# Plot the validation threshold trade-off
fig, ax = plt.subplots()
ax.plot(threshold_results['threshold'], threshold_results['relative_cost_units'], color=NAVY)
ax.axvline(triage_threshold, color=SCARLET, label=f'Locked threshold: {triage_threshold:.2f}')
ax.set(xlabel='Threshold for negative-review flag', ylabel='Relative error-cost units',
       title='Illustrative triage cost on validation only')
ax.legend()
fig.tight_layout()
plt.show()
Plot the validation threshold trade-off
Add a learning note Personal notes are saved in this browser.

Independent final evaluation

All decisions above are now fixed. The following test results describe this dataset and this trained run. They do not estimate performance on product reviews, South African languages, citizen complaints or scientific papers. Validation scores are optimistic after selection; the held-out test is the final assessment of the locked choices.

63. Transform the sealed test reviews

Use the already-fitted training vocabulary. There is no fit, adapt or tuning call here.

STEP 63Executed core# Link
Python code
# Transform the sealed test reviews
X_test = vectorizer.transform(test_df['text'])
print('Final test matrix:', X_test.shape)
Final test matrix: (9916, 50000)
Add a learning note Personal notes are saved in this browser.

64. Make final classical predictions

Store each score vector once so tables, charts and interpretations use the same evidence.

STEP 64Executed core# Link
Python code
# Make final classical predictions
test_scores, prediction_seconds = {}, {}
for name, model in core_models.items():
    start = perf_counter()
    test_scores[name] = model.predict_proba(X_test)[:, 1]
    prediction_seconds[name] = perf_counter() - start
Add a learning note Personal notes are saved in this browser.

65. Make final neural predictions when enabled

This runs only for actually trained neural models, using the frozen neural vocabulary.

STEP 65Optional · unexecuted# Link
Python code
# Make final neural predictions when enabled
if RUN_NEURAL:
    test_tokens = neural_vectorizer(test_df['text'].to_numpy()).numpy()
    for name, model in neural_models.items():
        start = perf_counter()
        test_scores[name] = model.predict(test_tokens, verbose=0).ravel()
        prediction_seconds[name] = perf_counter() - start
Add a learning note Personal notes are saved in this browser.

66. Check prediction integrity

Invalid scores or wrong row counts should fail before a result can be published.

STEP 66Executed core# Link
Python code
# Check prediction integrity
for name, score in test_scores.items():
    assert score.shape == y_test.shape, name
    assert np.isfinite(score).all(), name
    assert ((score >= 0) & (score <= 1)).all(), name
print('All executed models returned one valid score per held-out review.')
All executed models returned one valid score per held-out review.
Add a learning note Personal notes are saved in this browser.

67. Build the final comparison table

Prediction time excludes text cleaning and vectorisation; it is not a web-service latency measurement.

STEP 67Executed core# Link
Python code
# Build the final comparison table
test_results = pd.DataFrame([
    metric_summary(name, y_test, score) for name, score in test_scores.items()
])
test_results['prediction_seconds_without_preprocessing'] = test_results['model'].map(prediction_seconds)
display(test_results.round(4))
model accuracy balanced_accuracy macro_f1 negative_precision negative_recall roc_auc log_loss brier_score prediction_seconds_without_preprocessing
0 Majority baseline 0.5019 0.5000 0.3342 0.0000 0.0000 0.5000 0.6931 0.2500 0.0006
1 TF-IDF + Naive Bayes 0.8852 0.8852 0.8852 0.8890 0.8793 0.9524 0.3213 0.0945 0.0187
2 TF-IDF + Logistic Regression 0.9098 0.9098 0.9098 0.9204 0.8965 0.9700 0.2398 0.0696 0.0131
Add a learning note Personal notes are saved in this browser.

68. Interpret the selected result

Generate the narrative from the recorded predictions rather than typing scores from memory.

STEP 68Executed core# Link
Python code
# Interpret the selected result
selected_row = test_results.set_index('model').loc[selected_name]
selected_score = test_scores[selected_name]
selected_prediction = (selected_score >= 0.5).astype(int)
print(f'Validation selected {selected_name}.')
print(f'On {len(y_test):,} held-out reviews: accuracy {selected_row.accuracy:.2%}; '
      f'macro F1 {selected_row.macro_f1:.4f}.')
print(f'At the default 0.5 rule it caught {selected_row.negative_recall:.2%} of negative '
      f'reviews; negative precision was {selected_row.negative_precision:.2%}.')
print('These are local held-out results, not official benchmark or deployment scores.')
Validation selected TF-IDF + Logistic Regression.
On 9,916 held-out reviews: accuracy 90.98%; macro F1 0.9098.
At the default 0.5 rule it caught 89.65% of negative reviews; negative precision was 92.04%.
These are local held-out results, not official benchmark or deployment scores.
Add a learning note Personal notes are saved in this browser.

69. Plot test macro F1

Show the complete executed comparison, including the majority baseline.

STEP 69Executed core# Link
Python code
# Plot test macro F1
fig, ax = plt.subplots(figsize=(8, 4))
chart_data = test_results.set_index('model')['macro_f1'].sort_values()
ax.barh(chart_data.index, chart_data.values,
        color=[SCARLET if name == selected_name else NAVY for name in chart_data.index])
ax.set(xlim=(0, 1), xlabel='Macro F1 (0 to 1)', title='Held-out comparison at the fixed 0.5 rule')
ax.bar_label(ax.containers[0], fmt='%.3f', padding=3)
fig.tight_layout()
plt.show()
Plot test macro F1
Add a learning note Personal notes are saved in this browser.

70. Show the selected classification report

Both class rows matter. The support column gives the number of true examples in each class.

STEP 70Executed core# Link
Python code
# Show the selected classification report
class_report = pd.DataFrame(classification_report(
    y_test, selected_prediction, labels=[0, 1], target_names=['negative', 'positive'],
    output_dict=True, zero_division=0,
)).T
display(class_report.round(4))
precision recall f1-score support
negative 0.9204 0.8965 0.9083 4939.0000
positive 0.8999 0.9230 0.9113 4977.0000
accuracy 0.9098 0.9098 0.9098 0.9098
macro avg 0.9101 0.9098 0.9098 9916.0000
weighted avg 0.9101 0.9098 0.9098 9916.0000
Add a learning note Personal notes are saved in this browser.

71. Count the selected model’s errors

Rows are actual sentiment; columns are predicted sentiment. This orientation stays consistent in the chart.

STEP 71Executed core# Link
Python code
# Count the selected model’s errors
selected_cm = confusion_matrix(y_test, selected_prediction, labels=[0, 1])
display(pd.DataFrame(selected_cm, index=['actual negative', 'actual positive'],
                     columns=['predicted negative', 'predicted positive']))
assert int(selected_cm.sum()) == len(y_test)
assert np.isclose(np.trace(selected_cm) / selected_cm.sum(), selected_row.accuracy)
predicted negative predicted positive
actual negative 4428 511
actual positive 383 4594
Add a learning note Personal notes are saved in this browser.

72. Plot the confusion matrix

A missed negative is the top-right cell. A false negative-feedback alert is the bottom-left cell.

STEP 72Executed core# Link
Python code
# Plot the confusion matrix
fig, ax = plt.subplots(figsize=(5, 4))
ax.imshow(selected_cm, cmap='Blues')
for (row, column), count in np.ndenumerate(selected_cm):
    ax.text(column, row, f'{count:,}', ha='center', va='center',
            color='white' if count > selected_cm.max() / 2 else NAVY, fontsize=13)
ax.set(xticks=[0, 1], yticks=[0, 1], xticklabels=['Negative', 'Positive'],
       yticklabels=['Negative', 'Positive'], xlabel='Predicted sentiment',
       ylabel='Actual sentiment', title='Selected model: held-out errors')
fig.tight_layout()
plt.show()
Plot the confusion matrix
Add a learning note Personal notes are saved in this browser.

73. Explain the error counts in words

Connect the matrix to a negative-feedback queue without implying these are actual customer complaints.

STEP 73Executed core# Link
Python code
# Explain the error counts in words
true_negative, missed_negative = selected_cm[0]
false_alert, true_positive = selected_cm[1]
print(f'Default rule: {missed_negative:,} negative reviews were classified as positive.')
print(f'{false_alert:,} positive reviews would be incorrectly flagged as negative.')
print('The cost of these two mistakes can differ by organisation and use case.')
Default rule: 511 negative reviews were classified as positive.
383 positive reviews would be incorrectly flagged as negative.
The cost of these two mistakes can differ by organisation and use case.
Add a learning note Personal notes are saved in this browser.

74. Plot negative-class ranking quality

Use negative scores and negative labels so these curves directly describe the review queue.

STEP 74Executed core# Link
Python code
# Plot negative-class ranking quality
negative_truth = (y_test == 0).astype(int)
fig, axes = plt.subplots(1, 2, figsize=(10, 4))
RocCurveDisplay.from_predictions(negative_truth, 1 - selected_score, ax=axes[0], curve_kwargs={'color': NAVY})
PrecisionRecallDisplay.from_predictions(negative_truth, 1 - selected_score, ax=axes[1], color=SCARLET)
axes[0].set_title('Negative-review ROC curve')
axes[1].set_title('Negative-review precision–recall')
fig.tight_layout()
plt.show()
Plot negative-class ranking quality
Add a learning note Personal notes are saved in this browser.

75. Inspect probability calibration

A high score is not automatically a trustworthy probability. This diagnostic assesses the frozen model; it does not recalibrate on test data.

STEP 75Executed core# Link
Python code
# Inspect probability calibration
fraction_positive, mean_score = calibration_curve(y_test, selected_score, n_bins=10, strategy='quantile')
fig, ax = plt.subplots(figsize=(5, 4))
ax.plot([0, 1], [0, 1], '--', color=SLATE, label='Perfect calibration')
ax.plot(mean_score, fraction_positive, 'o-', color=NAVY, label=selected_name)
ax.set(xlabel='Mean predicted positive score', ylabel='Observed positive fraction',
       title='Test calibration diagnostic', xlim=(0, 1), ylim=(0, 1))
ax.legend(fontsize=8)
fig.tight_layout()
plt.show()
Inspect probability calibration
Add a learning note Personal notes are saved in this browser.

76. Define a bootstrap interval for a fixed model

Resample test rows 1,000 times. The interval measures row-sampling uncertainty conditional on this trained model, not training-seed or domain-shift uncertainty.

STEP 76Executed core# Link
Python code
# Define a bootstrap interval for a fixed model
def bootstrap_mean_interval(values, seed=RANDOM_STATE):
    values = np.asarray(values)
    rng = np.random.default_rng(seed)
    samples = rng.choice(values, size=(BOOTSTRAP_REPEATS, len(values)), replace=True)
    return np.quantile(samples.mean(axis=1), [0.025, 0.975])
Add a learning note Personal notes are saved in this browser.

77. Estimate test accuracy intervals

Review-level independence is an assumption. Hidden shared films/authors could make these intervals too narrow.

STEP 77Executed core# Link
Python code
# Estimate test accuracy intervals
interval_rows = []
for name, score in test_scores.items():
    correct = (score >= 0.5) == y_test
    lower, upper = bootstrap_mean_interval(correct)
    interval_rows.append({'model': name, 'accuracy': correct.mean(),
                          'bootstrap_95_lower': lower, 'bootstrap_95_upper': upper})
accuracy_intervals = pd.DataFrame(interval_rows)
display(accuracy_intervals.round(4))
model accuracy bootstrap_95_lower bootstrap_95_upper
0 Majority baseline 0.5019 0.4916 0.5118
1 TF-IDF + Naive Bayes 0.8852 0.8790 0.8918
2 TF-IDF + Logistic Regression 0.9098 0.9044 0.9156
Add a learning note Personal notes are saved in this browser.

78. Compare accuracy with a paired bootstrap

Use the same rows for both models. A descriptive interval that excludes zero supports a difference on this holdout, not universal superiority.

STEP 78Executed core# Link
Python code
# Compare accuracy with a paired bootstrap
comparison_name = ('TF-IDF + Naive Bayes' if selected_name != 'TF-IDF + Naive Bayes'
                   else 'TF-IDF + Logistic Regression')
selected_correct = ((selected_score >= 0.5) == y_test).astype('int8')
comparison_correct = ((test_scores[comparison_name] >= 0.5) == y_test).astype('int8')
accuracy_difference = selected_correct - comparison_correct
difference_interval = bootstrap_mean_interval(accuracy_difference)
print(f'Accuracy difference versus {comparison_name}: {accuracy_difference.mean():.2%}.')
print(f'Paired bootstrap 95% interval: {difference_interval[0]:.2%} to {difference_interval[1]:.2%}.')
print('The selected model remains the validation choice regardless of this test comparison.')
Accuracy difference versus TF-IDF + Naive Bayes: 2.46%.
Paired bootstrap 95% interval: 1.91% to 3.04%.
The selected model remains the validation choice regardless of this test comparison.
Add a learning note Personal notes are saved in this browser.

79. Assess the locked triage rule on the test set

Apply the validation-selected threshold once. Do not choose a better-looking threshold using these test results.

STEP 79Executed core# Link
Python code
# Assess the locked triage rule on the test set
test_triage = pd.DataFrame([
    dict(policy='Reference negative threshold 0.50',
         **triage_summary(y_test, selected_score, 0.50)),
    dict(policy='Validation-locked illustrative triage',
         **triage_summary(y_test, selected_score, triage_threshold)),
])
display(test_triage.round(4))
policy threshold flagged negative_recall negative_precision missed_negatives false_alerts relative_cost_units
0 Reference negative threshold 0.50 0.50 4811 0.8965 0.9204 511 383 2938
1 Validation-locked illustrative triage 0.25 5906 0.9698 0.8110 149 1116 1861
Add a learning note Personal notes are saved in this browser.

80. Interpret the triage trade-off

Queue volume is an operational constraint; the cost comparison is based on stated assumptions.

STEP 80Executed core# Link
Python code
# Interpret the triage trade-off
reference, triage = test_triage.iloc[0], test_triage.iloc[1]
print(f'At threshold {triage_threshold:.2f}: {int(triage.flagged):,} reviews are flagged '
      f'({triage.flagged / len(y_test):.1%} of this test set).')
print(f'Missed negatives: {int(reference.missed_negatives):,} → {int(triage.missed_negatives):,}; '
      f'false alerts: {int(reference.false_alerts):,} → {int(triage.false_alerts):,}.')
print(f'Assumed cost units: {int(reference.relative_cost_units):,} → {int(triage.relative_cost_units):,}.')
print('This is a movie-review simulation; no real service-time or revenue benefit was measured.')
At threshold 0.25: 5,906 reviews are flagged (59.6% of this test set).
Missed negatives: 511 → 149; false alerts: 383 → 1,116.
Assumed cost units: 2,938 → 1,861.
This is a movie-review simulation; no real service-time or revenue benefit was measured.
Add a learning note Personal notes are saved in this browser.

Explain and inspect errors

Model explanations show learned associations. They do not show that a word causes a person to feel positively, and they do not establish whether a review is true. Inspecting failures is more informative than presenting only a favourable example.

81. Inspect logistic-regression feature weights

Positive coefficients support the positive class; negative coefficients support the negative class. Correlated phrases share evidence.

STEP 81Executed core# Link
Python code
# Inspect logistic-regression feature weights
coefficients = logistic_model.coef_.ravel()
positive_indices = np.argsort(coefficients)[-12:][::-1]
negative_indices = np.argsort(coefficients)[:12]
feature_weights = pd.DataFrame({
    'negative_feature': feature_names[negative_indices],
    'negative_weight': coefficients[negative_indices],
    'positive_feature': feature_names[positive_indices],
    'positive_weight': coefficients[positive_indices],
})
display(feature_weights.round(3))
negative_feature negative_weight positive_feature positive_weight
0 worst -11.044 great 10.940
1 bad -10.931 excellent 8.716
2 awful -10.338 perfect 7.951
3 boring -9.613 wonderful 6.820
4 the worst -8.719 amazing 6.636
5 poor -8.385 best 5.893
6 waste -7.628 the best 5.424
7 terrible -7.582 loved 5.384
8 worse -6.900 enjoyable 5.296
9 disappointment -6.534 fun 5.106
10 stupid -6.454 brilliant 4.932
11 dull -6.451 hilarious 4.853
Add a learning note Personal notes are saved in this browser.

82. Plot interpretable term associations

These weights belong to the logistic baseline even if an optional neural model wins validation.

STEP 82Executed core# Link
Python code
# Plot interpretable term associations
indices = np.concatenate([negative_indices[:8], positive_indices[:8]])
indices = indices[np.argsort(coefficients[indices])]
fig, ax = plt.subplots(figsize=(8, 5))
ax.barh(feature_names[indices], coefficients[indices],
        color=[SCARLET if value < 0 else NAVY for value in coefficients[indices]])
ax.axvline(0, color=SLATE, linewidth=1)
ax.set(xlabel='Logistic coefficient: negative ← evidence → positive',
       title='Strong term associations in the fitted logistic model')
fig.tight_layout()
plt.show()
Plot interpretable term associations
Add a learning note Personal notes are saved in this browser.

83. Build a test-error audit table

Store row identifiers and short excerpts for inspection; export only IDs and scores, not full review text.

STEP 83Executed core# Link
Python code
# Build a test-error audit table
error_audit = test_df[['review_id', 'sentiment', 'text']].copy()
error_audit['predicted_sentiment'] = np.where(selected_prediction == 1, 'positive', 'negative')
error_audit['positive_score'] = selected_score
error_audit['wrong'] = selected_prediction != y_test
error_audit['excerpt'] = error_audit['text'].str.slice(0, 180) + '…'
Add a learning note Personal notes are saved in this browser.

84. Inspect confident missed negatives

These are negative-labelled reviews that the model calls positive. A high wrong score warns against treating confidence as certainty.

STEP 84Executed core# Link
Python code
# Inspect confident missed negatives
missed_examples = error_audit[(error_audit['sentiment'] == 'negative') & error_audit['wrong']]
display(missed_examples.sort_values('positive_score', ascending=False)
        [['excerpt', 'positive_score', 'predicted_sentiment']].head(5).round(4))
excerpt positive_score predicted_sentiment
7918 this is a great movie i love the series on tv and so i loved the movie one of the best things in the movie is that helga finally admits her deepest darkest secret to arnold tha... 0.9997 positive
8563 this one of the best celebrity's reality shows a ever saw we can see the concerts we can see the life of britney i love the five episodes i was always being surprised by britne... 0.9883 positive
6491 'ernest saves christmas' is comedian ernest's christmas special film in this film ernest has to find a successor to santa claus in order for christmas to continue along the way... 0.9864 positive
6043 yes the movie is not a piece of art but the first time i watched it i was 10 years old my parents were out and i stayed home with my two brothers it was may 1970 i know that be... 0.9827 positive
9132 although this isn't a great film there's something compelling and memorable about it like another commenter on the film i saw this in childhood it's been thirty three years sin... 0.9813 positive
Add a learning note Personal notes are saved in this browser.

85. Inspect confident false alerts

These are positive-labelled reviews that the model calls negative. Read the full original row locally before deciding why it failed.

STEP 85Executed core# Link
Python code
# Inspect confident false alerts
false_alert_examples = error_audit[(error_audit['sentiment'] == 'positive') & error_audit['wrong']]
display(false_alert_examples.sort_values('positive_score')
        [['excerpt', 'positive_score', 'predicted_sentiment']].head(5).round(4))
excerpt positive_score predicted_sentiment
9113 honestly i was expecting to hate this one and really only checked it out because jenna jameson is in it but i have to say i got a kick out of evil breed a group of college kids... 0.0075 negative
8816 comanche moon had everything going for it for starters simon wincer's back a man who's name is synonymous with high quality tv westerns unfortunately the problems with moon are... 0.0138 negative
2265 spoilers ahead it is really unfortunate that a movie so well produced turns out to be such a disappointment i thought this was full of silly clich s and that it basically tried... 0.0245 negative
9244 no real plot no character development no scorcese level direction but seriously were you really expecting any of this the only thing that matters is that this flick is absolute... 0.0294 negative
1645 who ever came up with story is one sick person i rented it for our slumber party sleepover and all six of us got freaked out cause we're all in an acting class together and we ... 0.0303 negative
Add a learning note Personal notes are saved in this browser.

Interpretation guide: look for mixed opinions, plot summaries, quotations, sarcasm, negation or a discrepancy between an excerpt and the rest of the review. These are questions for manual inspection, not automatically proven explanations of each error. Possible label noise should be adjudicated using the original text, not “fixed” to agree with the model. Excerpts are shortened for readability and can omit decisive context.

86. Define descriptive length groups

This is a post-evaluation diagnostic, not a reason to choose a new model on the same test set.

STEP 86Executed core# Link
Python code
# Define descriptive length groups
test_word_count = test_df['text'].str.split().str.len()
length_group = pd.cut(test_word_count, [0, 100, 200, 400, np.inf],
                      labels=['1–100', '101–200', '201–400', '401+'], include_lowest=True)
Add a learning note Personal notes are saved in this browser.

87. Compare errors across review lengths

Include row counts and class mix. Small slices have more sampling uncertainty; length is not a demographic fairness audit.

STEP 87Executed core# Link
Python code
# Compare errors across review lengths
slice_rows = []
for group in length_group.cat.categories:
    mask = (length_group == group).to_numpy()
    if mask.sum() == 0:
        continue
    row = metric_summary(str(group), y_test[mask], selected_score[mask])
    row.update(reviews=int(mask.sum()), negative_share=float((y_test[mask] == 0).mean()))
    slice_rows.append(row)
length_results = pd.DataFrame(slice_rows).rename(columns={'model': 'words'})
display(length_results[['words', 'reviews', 'negative_share', 'accuracy', 'macro_f1']].round(4))
words reviews negative_share accuracy macro_f1
0 1–100 1255 0.4486 0.9092 0.9083
1 101–200 4572 0.5057 0.9103 0.9103
2 201–400 2774 0.5281 0.9081 0.9080
3 401+ 1315 0.4555 0.9125 0.9117
Add a learning note Personal notes are saved in this browser.

What this evaluation cannot answer

  • There is no neutral, mixed or urgent-complaint target; the classifier forces a binary answer.
  • Missing author, film and date fields prevent grouped or future-period evaluation.
  • Exact deduplication does not exclude semantic near duplicates or shared film topics.
  • One split and one seed do not measure full training instability. The bootstrap intervals assume independently sampled reviews and condition on the fitted models.
  • The CSV contains no reliable demographic or language-group audit fields.
  • English movie reviews do not establish transfer performance on business, astronomy or government text.
  • The neural architectures have not been executed here. Their superiority is an open empirical question.
  • This is sentiment classification, not emotion diagnosis, fact checking, spam detection or causal impact analysis.

Useful next experiments are repeated/grouped validation where identifiers are available, temporal evaluation on newly collected data, local language labels, neutral/mixed categories, and a correctly separated probability-calibration study. Report any new test set separately.

Predict new reviews

88. Define prediction for new movie reviews

Reuse the training normaliser and vocabulary. Reject empty input rather than returning a confident-looking default.

STEP 88Executed core# Link
Python code
# Define prediction for new movie reviews
def predict_movie_reviews(reviews):
    texts = [normalise_text(review) for review in reviews]
    if not texts or any(not text for text in texts):
        raise ValueError('Supply at least one non-empty review.')
    if selected_name in core_models:
        scores = core_models[selected_name].predict_proba(vectorizer.transform(texts))[:, 1]
    else:
        tokens = neural_vectorizer(np.asarray(texts, dtype=object)).numpy()
        scores = neural_models[selected_name].predict(tokens, verbose=0).ravel()
    return pd.DataFrame({
        'input': reviews, 'positive_score': scores,
        'binary_sentiment': np.where(scores >= 0.5, 'positive', 'negative'),
        'illustrative_negative_flag': (1 - scores) >= triage_threshold,
    })
Add a learning note Personal notes are saved in this browser.

89. Try clear, negated, mixed and neutral examples

These examples illustrate behaviour. They are not an independently labelled accuracy test.

STEP 89Executed core# Link
Python code
# Try clear, negated, mixed and neutral examples
demo_reviews = [
    'A wonderful film with brilliant acting. I would happily watch it again.',
    'A terrible movie. The boring story and poor acting wasted my evening.',
    'This movie was not bad at all; I enjoyed it more than I expected.',
    'I loved the visuals but hated the story.',
    'The movie was released on Friday and runs for two hours.',
]
display(predict_movie_reviews(demo_reviews).round(4))
print('Mixed and neutral statements still receive a forced binary label.')
print('A score is a model estimate, not a verified probability of a human opinion.')
input positive_score binary_sentiment illustrative_negative_flag
0 A wonderful film with brilliant acting. I would happily watch it again. 0.9924 positive False
1 A terrible movie. The boring story and poor acting wasted my evening. 0.0006 negative True
2 This movie was not bad at all; I enjoyed it more than I expected. 0.6552 positive True
3 I loved the visuals but hated the story. 0.8407 positive False
4 The movie was released on Friday and runs for two hours. 0.2617 negative True
Mixed and neutral statements still receive a forced binary label.
A score is a model estimate, not a verified probability of a human opinion.
Add a learning note Personal notes are saved in this browser.

Business, astronomy and government

The transferable asset is the analysis workflow. The IMDb-trained weights have only been assessed on movie reviews. Each application below is a proposed adaptation requiring new representative text, suitable labels and its own held-out evaluation.

Perspective Proposed use Decision supported Additional data or model change Pilot measures
Business: customer experience Flag negative product/service feedback for review Allocate attention and investigate recurring issues Domain-labelled reviews; neutral/mixed labels; aspect/topic labels Negative recall, alert precision, queue size, time to review, resolved cases
Business: marketing Summarise reactions to a campaign Identify concerns to investigate before a creative change Campaign/channel/time metadata; representative samples Theme coverage and human agreement; engagement and conversion evaluated separately
Business: internal audit Reproduce a text-analysis report Check exclusions, splits, model versions and error trade-offs Reviewed labels; documented costs; export provenance Reconciled row counts, stable split IDs, reproducible outputs, traceable overrides
Astronomy: research literature Categorise abstracts or observation proposals Help researchers find relevant work Astronomy corpus; topic/entity labels; domain-specific representation Per-topic F1, retrieval recall, expert relevance, minutes saved per search
Astronomy: observatory operations Route instrument or observation log text Help staff review technical reports Labelled operational logs; severity and instrument fields Routing accuracy, critical-event recall, false alerts, review workload
Government: citizen services Route and summarise public service feedback Identify service issues for human review Local languages; service/location labels; neutral/mixed text; adjudicated samples Per-language accuracy, critical-issue recall, queue size, response times
Government: consultation analysis Organise consultation submissions into themes Help analysts inspect viewpoints and recurring concerns Multi-label themes; provenance; representative/manual validation Theme coverage, minority-view retention, human agreement, processing time

Business interpretation

Sentiment can help prioritise which feedback to read. It does not tell a manager which product defect caused an opinion, whether a customer will leave, or whether a campaign caused sales. Those questions need topic/aspect labels, operational outcomes and a separate evaluation design. Auditability is already demonstrated here through data reconciliation, source fingerprinting, split IDs and consistent metrics. A live pilot should compare the model-assisted workflow with the current manual workflow before claiming an improvement.

Astronomy interpretation

An astronomer could adapt text classification to scientific abstracts or observatory logs. The astroBERT research paper describes domain-specific NLP for NASA ADS literature discovery and named-entity recognition. It supports the relevance of scientific-text modelling; it does not validate this sentiment model for astronomy. Astronomical terminology and labels require a new corpus and evaluation. Images, spectra, stellar properties and light curves would require different input representations and tasks. No astrophysical finding or astronomical classification is produced in this project.

Government interpretation

The OECD’s 2025 account of AI in civic participation describes text analysis, topic clustering and sentiment analysis for consultation inputs. The proposed extension here is to help analysts organise submissions and locate concerns. Sentiment cannot establish policy correctness or population support: participants and online feedback may be unrepresentative. A South African adaptation needs language coverage, neutral/mixed labels and checks that unusual or minority views remain visible. Human analysts should retain the original submissions and responsibility for interpretation.

90. Declare an illustrative business capacity scenario

These editable inputs are assumptions for a planning calculation. They are not inferred from the IMDb results or a real business.

STEP 90Executed core# Link
Python code
# Declare an illustrative business capacity scenario
MONTHLY_TEXT_ITEMS = 10_000
MANUAL_MINUTES_PER_ITEM = 2.0
ASSUMED_REVIEW_FRACTION = 0.35
ASSISTED_MINUTES_PER_REVIEW = 1.5
MONTHLY_OVERSIGHT_HOURS = 15.0
ASSUMED_STAFF_COST_PER_HOUR_ZAR = 180.0
ASSUMED_PLATFORM_COST_PER_MONTH_ZAR = 4_500.0
Add a learning note Personal notes are saved in this browser.

91. Calculate the assumed capacity value

Freed staff time is capacity value, not necessarily cash savings. The calculation excludes setup costs and the cost of errors.

STEP 91Executed core# Link
Python code
# Calculate the assumed capacity value
manual_hours = MONTHLY_TEXT_ITEMS * MANUAL_MINUTES_PER_ITEM / 60
assisted_hours = (MONTHLY_TEXT_ITEMS * ASSUMED_REVIEW_FRACTION
                  * ASSISTED_MINUTES_PER_REVIEW / 60 + MONTHLY_OVERSIGHT_HOURS)
hours_freed = manual_hours - assisted_hours
capacity_value = hours_freed * ASSUMED_STAFF_COST_PER_HOUR_ZAR
display(pd.DataFrame({
    'scenario_measure': ['Manual hours', 'Assisted + oversight hours', 'Hours freed',
                         'Assigned capacity value (ZAR)', 'Platform cost assumption (ZAR)',
                         'Capacity value less platform cost (ZAR)'],
    'assumed_value': [manual_hours, assisted_hours, hours_freed, capacity_value,
                      ASSUMED_PLATFORM_COST_PER_MONTH_ZAR,
                      capacity_value - ASSUMED_PLATFORM_COST_PER_MONTH_ZAR],
}).round(2))
print('Illustrative scenario only. Replace every input with measured pilot evidence.')
scenario_measure assumed_value
0 Manual hours 333.33
1 Assisted + oversight hours 102.50
2 Hours freed 230.83
3 Assigned capacity value (ZAR) 41550.00
4 Platform cost assumption (ZAR) 4500.00
5 Capacity value less platform cost (ZAR) 37050.00
Illustrative scenario only. Replace every input with measured pilot evidence.
Add a learning note Personal notes are saved in this browser.

92. Show sensitivity to the assumed review fraction

More text requiring human review reduces the capacity benefit. This sensitivity is not a deployment forecast.

STEP 92Executed core# Link
Python code
# Show sensitivity to the assumed review fraction
review_fractions = np.array([0.20, 0.35, 0.50, 0.75, 1.00])
scenario_hours = (MONTHLY_TEXT_ITEMS * review_fractions * ASSISTED_MINUTES_PER_REVIEW / 60
                   + MONTHLY_OVERSIGHT_HOURS)
display(pd.DataFrame({'assumed_review_fraction': review_fractions,
                      'human_hours_required': scenario_hours,
                      'hours_freed_vs_manual': manual_hours - scenario_hours}).round(2))
assumed_review_fraction human_hours_required hours_freed_vs_manual
0 0.20 65.0 268.33
1 0.35 102.5 230.83
2 0.50 140.0 193.33
3 0.75 202.5 130.83
4 1.00 265.0 68.33
Add a learning note Personal notes are saved in this browser.

Evidence required for a useful pilot: collect a representative sample and agree on labels; compare annotation disagreements; reserve future-period data; measure precision, recall and workload; then measure actual review time and outcomes. Set the threshold using organisation costs and available reviewers. Keep model errors and human overrides inspectable. This is a concrete extension plan, not a claim that the IMDb classifier is ready for those domains.

Reproducibility and publishing

The following cells export the evidence behind the page. Split and prediction exports use review IDs rather than review text. The fingerprint records which source CSV they belong to. The saved core classifier expects normalised text, using the function shown above; predict_core.py in the package applies that function before prediction.

Only load joblib/pickle model files from a trusted source. The package’s model artifact is an educational snapshot and must be loaded with the recorded scikit-learn version.

93. Build the data-quality reconciliation

The exclusions must add back to the source row count.

STEP 93Executed core# Link
Python code
# Build the data-quality reconciliation
quality_audit = pd.DataFrame({
    'measure': ['raw_rows', 'raw_exact_duplicate_rows', 'normalised_repeated_rows',
                'quarantined_rows', 'eligible_duplicates_removed', 'retained_unique_rows',
                'training_rows', 'validation_rows', 'test_rows'],
    'count': [len(raw), raw_duplicate_rows, normalised_duplicate_rows, quarantined_rows,
              duplicates_removed, len(clean), len(train_df), len(val_df), len(test_df)],
})
display(quality_audit)
measure count
0 raw_rows 50000
1 raw_exact_duplicate_rows 418
2 normalised_repeated_rows 423
3 quarantined_rows 0
4 eligible_duplicates_removed 423
5 retained_unique_rows 49577
6 training_rows 29745
7 validation_rows 9916
8 test_rows 9916
Add a learning note Personal notes are saved in this browser.

94. Export the score and audit tables

These CSVs support inspection of the exact published run.

STEP 94Executed core# Link
Python code
# Export the score and audit tables
quality_audit.to_csv(OUTPUT_DIR / 'data_quality.csv', index=False)
tuning_results.to_csv(OUTPUT_DIR / 'logistic_validation_search.csv', index=False)
validation_results.to_csv(OUTPUT_DIR / 'validation_metrics.csv', index=False)
test_results.to_csv(OUTPUT_DIR / 'test_metrics.csv', index=False)
accuracy_intervals.to_csv(OUTPUT_DIR / 'accuracy_intervals.csv', index=False)
threshold_results.to_csv(OUTPUT_DIR / 'validation_thresholds.csv', index=False)
test_triage.to_csv(OUTPUT_DIR / 'test_triage.csv', index=False)
length_results.to_csv(OUTPUT_DIR / 'test_length_slices.csv', index=False)
feature_weights.to_csv(OUTPUT_DIR / 'logistic_feature_weights.csv', index=False)
print('Saved metric, data-quality and diagnostic tables.')
Saved metric, data-quality and diagnostic tables.
Add a learning note Personal notes are saved in this browser.

95. Export partition membership

This allows another analyst with the same normaliser and source CSV to reconstruct the partitions.

STEP 95Executed core# Link
Python code
# Export partition membership
split_manifest = pd.concat([
    frame[['review_id', 'source_row', 'label']].assign(split=name)
    for name, frame in [('train', train_df), ('validation', val_df), ('test', test_df)]
], ignore_index=True)
split_manifest.to_csv(OUTPUT_DIR / 'split_manifest.csv', index=False)
assert split_manifest['review_id'].is_unique
print(f'Saved {len(split_manifest):,} partition records without review text.')
Saved 49,577 partition records without review text.
Add a learning note Personal notes are saved in this browser.

96. Export final selected predictions

The selected score is used consistently in both the classification and illustrative triage policy.

STEP 96Executed core# Link
Python code
# Export final selected predictions
prediction_export = test_df[['review_id', 'source_row', 'label']].copy()
prediction_export['selected_model'] = selected_name
prediction_export['positive_score'] = selected_score
prediction_export['predicted_label_default'] = selected_prediction
prediction_export['negative_flag_locked_policy'] = (1 - selected_score) >= triage_threshold
prediction_export.to_csv(OUTPUT_DIR / 'selected_test_predictions.csv', index=False)
print(f'Saved {len(prediction_export):,} final prediction records without review text.')
Saved 9,916 final prediction records without review text.
Add a learning note Personal notes are saved in this browser.

97. Save the strongest classical pipeline components

This snapshot contains fitted features and classifier together, with an explicit input contract.

STEP 97Executed core# Link
Python code
# Save the strongest classical pipeline components
core_snapshot = {
    'vectorizer': vectorizer, 'classifier': best_core_model,
    'model_name': best_core_name, 'labels': label_map,
    'input_contract': 'Apply normalise_text before vectorizer.transform.',
    'source_sha256': source_sha256,
    'sklearn_version': importlib.metadata.version('scikit-learn'),
}
joblib.dump(core_snapshot, OUTPUT_DIR / 'sentiment_core.joblib', compress=3)
reloaded_core = joblib.load(OUTPUT_DIR / 'sentiment_core.joblib')
assert np.allclose(reloaded_core['classifier'].predict_proba(X_val[:5])[:, 1],
                   validation_scores[best_core_name][:5])
print('Saved sentiment_core.joblib; normalised movie-review text is required.')
Saved sentiment_core.joblib; normalised movie-review text is required.
Add a learning note Personal notes are saved in this browser.

98. Save optional neural artifacts only after training

Native Keras weights require the matching vocabulary and sequence length to reproduce inference.

STEP 98Optional · unexecuted# Link
Python code
# Save optional neural artifacts only after training
if RUN_NEURAL:
    for name, model in neural_models.items():
        slug = re.sub(r'[^a-z0-9]+', '_', name.lower()).strip('_')
        model.save(OUTPUT_DIR / f'{slug}.keras')
    neural_encoding = {'vocabulary': neural_vectorizer.get_vocabulary(),
                       'max_length': MAX_LENGTH, 'standardize': None}
    (OUTPUT_DIR / 'neural_encoding.json').write_text(json.dumps(neural_encoding, indent=2))
Add a learning note Personal notes are saved in this browser.

99. Record the run configuration and dependency versions

State what actually ran, and record the uncertainty assumptions rather than hiding them.

STEP 99Executed core# Link
Python code
# Record the run configuration and dependency versions
versions = {name: importlib.metadata.version(name) for name in
            ['numpy', 'pandas', 'scipy', 'scikit-learn', 'matplotlib', 'joblib']}
run_manifest = {
    'author': 'Gareth Grant', 'dataset_file': DATA_PATH.name, 'dataset_sha256': source_sha256,
    'python': platform.python_version(), 'dependencies': versions, 'random_state': RANDOM_STATE,
    'raw_rows': len(raw), 'unique_rows': len(clean),
    'splits': {'train': len(train_df), 'validation': len(val_df), 'test': len(test_df)},
    'tfidf_max_features': TFIDF_MAX_FEATURES, 'logistic_candidates_C': LOGISTIC_C_VALUES,
    'locked_decisions': locked_plan, 'bootstrap_repeats': BOOTSTRAP_REPEATS,
    'bootstrap_assumption': 'Independent test rows, conditional on fixed trained models.',
    'neural_requested': RUN_NEURAL, 'neural_models_trained': list(neural_models),
    'official_stanford_split': False,
}
if RUN_NEURAL:
    run_manifest['tensorflow_version'] = tf.__version__
(OUTPUT_DIR / 'run_manifest.json').write_text(json.dumps(run_manifest, indent=2))
display(pd.Series(versions, name='version').to_frame())
version
numpy 2.3.5
pandas 2.2.3
scipy 1.17.0
scikit-learn 1.8.0
matplotlib 3.10.8
joblib 1.5.3
Add a learning note Personal notes are saved in this browser.

100. Generate the portfolio conclusion

The conclusion changes when the experiment is rerun. It describes evidence and practical limits.

STEP 100Executed core# Link
Python code
# Generate the portfolio conclusion
accuracy_ci = accuracy_intervals.set_index('model').loc[selected_name]
print(f'Selected on validation: {selected_name}.')
print(f'Final accuracy: {selected_row.accuracy:.2%}; macro F1: {selected_row.macro_f1:.4f}.')
print(f'Conditional bootstrap 95% accuracy interval: '
      f'{accuracy_ci.bootstrap_95_lower:.2%}–{accuracy_ci.bootstrap_95_upper:.2%}.')
print(f'Data audit: {len(raw):,} source rows → {len(clean):,} unique valid reviews; '
      f'{duplicates_removed:,} eligible duplicates removed.')
print('The workflow demonstrates reproducible text analytics, interpretable baselines, '
      'error analysis and an explicit operational trade-off.')
print('Business, astronomy and government are adaptation proposals, not validated deployment domains.')
print('Neural experiments:', 'trained in this run' if neural_models else 'not run; no performance claim')
Selected on validation: TF-IDF + Logistic Regression.
Final accuracy: 90.98%; macro F1: 0.9098.
Conditional bootstrap 95% accuracy interval: 90.44%–91.56%.
Data audit: 50,000 source rows → 49,577 unique valid reviews; 423 eligible duplicates removed.
The workflow demonstrates reproducible text analytics, interpretable baselines, error analysis and an explicit operational trade-off.
Business, astronomy and government are adaptation proposals, not validated deployment domains.
Neural experiments: not run; no performance claim
Add a learning note Personal notes are saved in this browser.

How to put this on the website

Use the included standalone HTML edition as a reading page, and offer this .ipynb as the downloadable notebook. Its page navigation and code toggles let a recruiter read the story and a learner inspect individual steps. All charts and styles are embedded; no runtime API or model service is needed to view the page. Publishing the HTML does not run its Python or turn the example predictions into a live predictor.

Keep the HTML and notebook from the same run. After editing or rerunning the notebook, regenerate the web preview, for example with jupyter nbconvert --to html and your preferred styling. The polished preview supplied here was built from the executed cell outputs.

Suggested portfolio description: “I rebuilt a sentiment analysis workflow to audit text quality, prevent duplicate leakage, compare simple and complex model ideas, explain errors and assess a decision threshold. The report distinguishes tested results from proposed business and public-sector applications.”

Practice and references

Short exercises for learners

  1. Explain why the vocabulary must be fitted after the split. Add your answer beside the TF-IDF fit cell.
  2. Remove bigrams, rerun using development data, and explain the effect on negation. Use a new final holdout for a new published comparison.
  3. Explain the top-right confusion-matrix cell in the context of a negative-feedback queue.
  4. Change the assumed missed-negative cost in the development study. Predict how the chosen threshold and queue volume should change before running it.
  5. Read a full misclassified review locally. Suggest a failure category without changing its label to match the model.
  6. Explain why an English movie-review accuracy score cannot establish performance on citizen complaints or astronomy abstracts.
  7. After installing TensorFlow, enable the optional section. Compare validation results, learning curves, parameter counts and training effort before testing a locked choice.

References

Sources checked 4 October 2026. Dataset interpretations above use the supplied file; external sources support the task background and proposed applications. The optional neural code uses the modern TensorFlow/Keras API; it remains unexecuted in the supplied edition.

Gareth Grant · Sentiment analysis portfolio · The notebook and this page share the same executed results.
Code copied to clipboard