Explore the analysis
01Question & design02Setup03Data quality04Independent splits05Training exploration06Text features07Classical models08Optional neural code09Model & threshold10Held-out results11Explain & inspect12Example predictions13Real-world adaptations14Exports & publishing15Exercises & sourcesFrom 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.
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
- Question and study design
- Setup
- Data audit and cleaning
- Split before learning
- Explore the training data
- Turn text into features
- Classical models
- Optional neural experiments
- Lock the model and operating rule
- Independent final evaluation
- Explain and inspect errors
- Predict new reviews
- Business, astronomy and government
- Reproducibility and publishing
- 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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()
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.
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()
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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()
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.
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.
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.
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.
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.
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.
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.
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()
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.
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.
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.
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()
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.
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.
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()
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.
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()
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.
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.
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.
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.
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.
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.
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.
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()
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
- Explain why the vocabulary must be fitted after the split. Add your answer beside the TF-IDF fit cell.
- Remove bigrams, rerun using development data, and explain the effect on negation. Use a new final holdout for a new published comparison.
- Explain the top-right confusion-matrix cell in the context of a negative-feedback queue.
- Change the assumed missed-negative cost in the development study. Predict how the chosen threshold and queue volume should change before running it.
- Read a full misclassified review locally. Suggest a failure category without changing its label to match the model.
- Explain why an English movie-review accuracy score cannot establish performance on citizen complaints or astronomy abstracts.
- After installing TensorFlow, enable the optional section. Compare validation results, learning curves, parameter counts and training effort before testing a locked choice.
References
- Maas, A. L., Daly, R. E., Pham, P. T., Huang, D., Ng, A. Y. and Potts, C. (2011). Learning Word Vectors for Sentiment Analysis. ACL, pp. 142–150. Dataset source and original split.
- TensorFlow. Text classification with an RNN.
- Keras. EarlyStopping.
- Grezes, F. et al. (2021). Building astroBERT, a language model for Astronomy & Astrophysics.
- OECD (2025). AI in civic participation and open government, in Governing with Artificial Intelligence.
- Supplied CSV redistribution originally referenced in the source notebooks: IMDb Dataset of 50K Movie Reviews. The CSV content was audited locally; its original split metadata is not retained.
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.