import pandas as pd
df = pd.read_csv("data/imdb_reviews_sample.csv")
df.head()
df["sentiment"].value_counts()sentiment
negative 2532
positive 2468
Name: count, dtype: int64
import pandas as pd
df = pd.read_csv("data/imdb_reviews_sample.csv")
df.head()
df["sentiment"].value_counts()sentiment
negative 2532
positive 2468
Name: count, dtype: int64
from sklearn.model_selection import train_test_split
df = df.dropna(subset=["review", "sentiment"]).copy()
# Many IMDB review exports contain HTML line breaks such as <br />.
# This light cleaning keeps the task simple but removes a common artifact.
df["clean_review"] = (
df["review"]
.str.replace(r"<br\s*/?>", " ", regex=True)
.str.replace(r"\s+", " ", regex=True)
.str.strip()
)
df["label"] = df["sentiment"].map({"negative": 0, "positive": 1})
X = df["clean_review"]
y = df["label"]
X_train, X_test, y_train, y_test = train_test_split(
X,
y,
test_size=0.25,
random_state=42,
stratify=y,
)
print(X_train.shape, X_test.shape)
print(y_train.value_counts(normalize=True))
print(y_test.value_counts(normalize=True))(3750,) (1250,)
label
0 0.5064
1 0.4936
Name: proportion, dtype: float64
label
0 0.5064
1 0.4936
Name: proportion, dtype: float64
from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer
count_vectorizer = CountVectorizer(
stop_words="english",
max_features=5000,
)
X_train_bow = count_vectorizer.fit_transform(X_train)
X_test_bow = count_vectorizer.transform(X_test)
tfidf_vectorizer = TfidfVectorizer(
stop_words="english",
max_features=5000,
min_df=2,
ngram_range=(1, 2),
)
X_train_tfidf = tfidf_vectorizer.fit_transform(X_train)
X_test_tfidf = tfidf_vectorizer.transform(X_test)
sparsity = 1 - (X_train_tfidf.nnz / (X_train_tfidf.shape[0] * X_train_tfidf.shape[1]))
print("Bag-of-Words shape:", X_train_bow.shape)
print("TF-IDF shape:", X_train_tfidf.shape)
print("TF-IDF sparsity:", round(sparsity, 4))Bag-of-Words shape: (3750, 5000)
TF-IDF shape: (3750, 5000)
TF-IDF sparsity: 0.9854
Expected interpretation:
import numpy as np
review_id = 0
feature_names = np.array(tfidf_vectorizer.get_feature_names_out())
row = X_train_tfidf[review_id].toarray().ravel()
top_positions = row.argsort()[::-1][:15]
top_features = pd.DataFrame({
"feature": feature_names[top_positions],
"tfidf": row[top_positions],
})
display(X_train.iloc[review_id])
display(top_features)'Worst Movie I Have Ever Seen! 90 Minutes of excruciating film-making. All the ingredients to make this movie a true work of CRAP. Bad acting, bad directing, bad storytelling, bad makeup, bad dialogue, bad effects, and bad reasoning behind certain actions taken by the characters. It also threw in a terrible naked shot of a dumb blond, and a breast shot of a stupid Asian girl, and both attempts were just scary, since these girls are ugly. Some good horror movies came out of the 80s, but this could never be considered one of them. Kevin Tenney also committed one of the greatest sins in storytelling: he introduced characters at the end of the movie (an Old Man and Old Woman). I would vote for it below a 1 out of 10 but the voting system doesn\'t work that way apparently. Right from the title sequence I knew it would suck and I would return my DVD but Best Buy doesn\'t refund DVDs, or consumable products as they call it, or so my receipt says. I have "The Dunwich Horror" and that was truly god-awful, but I still feel that "Night of the Demons" (an obvious Evil Dead RIP-OFF) was far worse than "Dunwich Horror." This is just like "The Howling," how in the hell could sequels get milked out of this anorexic cow??? Save your money and get the "Texas Chainsaw Massacre" (just don\'t get any of its sequels though) or "The Evil Dead" or "Dawn of the Dead." "Night of the Demons" is a very, very, very bad investment. Every second of it was just maddening, excruciating pain for the audience, because the whole movie all-around was horrible! Do yourself a favor, DON\'T SEE IT! You\'ll be saving some brain cells.'
| feature | tfidf | |
|---|---|---|
| 0 | bad | 0.318879 |
| 1 | evil dead | 0.217983 |
| 2 | storytelling | 0.197931 |
| 3 | demons | 0.197931 |
| 4 | dead | 0.190483 |
| 5 | sequels | 0.188522 |
| 6 | horror | 0.168756 |
| 7 | evil | 0.132952 |
| 8 | night | 0.121025 |
| 9 | shot | 0.120767 |
| 10 | just | 0.120738 |
| 11 | blond | 0.110300 |
| 12 | breast | 0.106651 |
| 13 | chainsaw | 0.105596 |
| 14 | ingredients | 0.105596 |
A good answer should recognize that TF-IDF features are interpretable because they correspond to words or short phrases. However, they mainly show lexical signals. They do not fully capture context, irony, negation, or meaning beyond word occurrence.
from sklearn.metrics.pairwise import cosine_similarity
query_id = 0
similarities = cosine_similarity(X_train_tfidf[query_id], X_train_tfidf).ravel()
top_ids = similarities.argsort()[::-1][1:6]
similar_reviews_tfidf = pd.DataFrame({
"similarity": similarities[top_ids],
"sentiment": y_train.iloc[top_ids].map({0: "negative", 1: "positive"}).values,
"review": X_train.iloc[top_ids].values,
})
print("Query review:")
print(X_train.iloc[query_id])
print("Query sentiment:", "positive" if y_train.iloc[query_id] == 1 else "negative")
display(similar_reviews_tfidf)Query review:
Worst Movie I Have Ever Seen! 90 Minutes of excruciating film-making. All the ingredients to make this movie a true work of CRAP. Bad acting, bad directing, bad storytelling, bad makeup, bad dialogue, bad effects, and bad reasoning behind certain actions taken by the characters. It also threw in a terrible naked shot of a dumb blond, and a breast shot of a stupid Asian girl, and both attempts were just scary, since these girls are ugly. Some good horror movies came out of the 80s, but this could never be considered one of them. Kevin Tenney also committed one of the greatest sins in storytelling: he introduced characters at the end of the movie (an Old Man and Old Woman). I would vote for it below a 1 out of 10 but the voting system doesn't work that way apparently. Right from the title sequence I knew it would suck and I would return my DVD but Best Buy doesn't refund DVDs, or consumable products as they call it, or so my receipt says. I have "The Dunwich Horror" and that was truly god-awful, but I still feel that "Night of the Demons" (an obvious Evil Dead RIP-OFF) was far worse than "Dunwich Horror." This is just like "The Howling," how in the hell could sequels get milked out of this anorexic cow??? Save your money and get the "Texas Chainsaw Massacre" (just don't get any of its sequels though) or "The Evil Dead" or "Dawn of the Dead." "Night of the Demons" is a very, very, very bad investment. Every second of it was just maddening, excruciating pain for the audience, because the whole movie all-around was horrible! Do yourself a favor, DON'T SEE IT! You'll be saving some brain cells.
Query sentiment: negative
| similarity | sentiment | review | |
|---|---|---|---|
| 0 | 0.262307 | positive | The 80's is largely considered the decade in w... |
| 1 | 0.211370 | negative | Why is it that Canada can turn out decent to g... |
| 2 | 0.207395 | negative | Avoid this crap at all costs. Bad script, bad ... |
| 3 | 0.202169 | positive | most of the bad reviews on this website blame ... |
| 4 | 0.202030 | negative | This movie has to be one of the most boring an... |
Expected interpretation:
from sentence_transformers import SentenceTransformer
embedding_model = SentenceTransformer("all-MiniLM-L6-v2")
X_train_emb = embedding_model.encode(
X_train.tolist(),
show_progress_bar=True,
normalize_embeddings=True,
)
X_test_emb = embedding_model.encode(
X_test.tolist(),
show_progress_bar=True,
normalize_embeddings=True,
)
print("Training embeddings:", X_train_emb.shape)
print("Test embeddings:", X_test_emb.shape)Expected interpretation:
all-MiniLM-L6-v2, each review is represented by a 384-dimensional vector.from sklearn.metrics.pairwise import cosine_similarity
query_id = 0
similarities_dense = cosine_similarity(
X_train_emb[query_id].reshape(1, -1),
X_train_emb,
).ravel()
top_ids_dense = similarities_dense.argsort()[::-1][1:6]
similar_reviews_dense = pd.DataFrame({
"similarity": similarities_dense[top_ids_dense],
"sentiment": y_train.iloc[top_ids_dense].map({0: "negative", 1: "positive"}).values,
"review": X_train.iloc[top_ids_dense].values,
})
print("Query review:")
print(X_train.iloc[query_id])
print("Query sentiment:", "positive" if y_train.iloc[query_id] == 1 else "negative")
display(similar_reviews_dense)Expected interpretation:
Dense embeddings often retrieve reviews that are similar in overall meaning even if they do not share many exact words. However, dense similarity is not identical to sentiment similarity. Two reviews can be semantically close because they discuss similar topics, genres, actors, or plot elements, while still expressing different evaluations.
from sklearn.decomposition import PCA
import matplotlib.pyplot as plt
sample_size = min(500, len(df))
sample_df = df.sample(n=sample_size, random_state=42).copy()
sample_embeddings = embedding_model.encode(
sample_df["review"].tolist(),
show_progress_bar=True,
normalize_embeddings=True,
)
pca = PCA(n_components=2, random_state=42)
coords = pca.fit_transform(sample_embeddings)
sample_df["dim_1"] = coords[:, 0]
sample_df["dim_2"] = coords[:, 1]
plt.figure(figsize=(8, 6))
plt.scatter(
sample_df["dim_1"],
sample_df["dim_2"],
c=sample_df["label"],
alpha=0.65,
)
plt.xlabel("PCA dimension 1")
plt.ylabel("PCA dimension 2")
plt.title("Dense review embeddings reduced to two dimensions")
plt.show()Expected interpretation:
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
import time
start_time = time.time()
tfidf_clf = LogisticRegression(max_iter=1000)
tfidf_clf.fit(X_train_tfidf, y_train)
y_pred_tfidf = tfidf_clf.predict(X_test_tfidf)
tfidf_train_time = time.time() - start_time
tfidf_accuracy = accuracy_score(y_test, y_pred_tfidf)
print("Accuracy:", round(tfidf_accuracy, 3))
print("Training time:", round(tfidf_train_time, 3), "seconds")
print(classification_report(y_test, y_pred_tfidf, target_names=["negative", "positive"]))
print(confusion_matrix(y_test, y_pred_tfidf))A good interpretation should note that TF-IDF combined with logistic regression is often a strong baseline for sentiment classification. It is relatively interpretable because features correspond to words or phrases, and coefficients can be inspected.
start_time = time.time()
dense_clf = LogisticRegression(max_iter=1000)
dense_clf.fit(X_train_emb, y_train)
y_pred_dense = dense_clf.predict(X_test_emb)
dense_train_time = time.time() - start_time
dense_accuracy = accuracy_score(y_test, y_pred_dense)
print("Accuracy:", round(dense_accuracy, 3))
print("Training time:", round(dense_train_time, 3), "seconds")
print(classification_report(y_test, y_pred_dense, target_names=["negative", "positive"]))
print(confusion_matrix(y_test, y_pred_dense))Expected interpretation:
The dense model may perform better, worse, or similarly depending on the dataset size, model, and preprocessing. Generic pretrained embeddings are useful because they encode semantic information, but TF-IDF can be very competitive for supervised sentiment classification. The important learning outcome is not that dense vectors always win, but that they represent texts differently.
results = pd.DataFrame(
{
"representation": ["TF-IDF", "Dense embeddings"],
"accuracy": [tfidf_accuracy, dense_accuracy],
"training_time_seconds": [tfidf_train_time, dense_train_time],
}
)
resultsA possible interpretation:
| Criterion | TF-IDF + logistic regression | Dense embeddings + logistic regression |
|---|---|---|
| Accuracy | Often strong for sentiment | Often strong, but not automatically better |
| Training time | Usually fast | Embedding creation may be slower |
| Feature interpretability | High | Lower |
| Captures exact words | Yes | Less directly |
| Captures semantic similarity | Limited | Stronger |
The comparison should emphasize trade-offs rather than a single winner.
edge_cases = pd.DataFrame(
{
"review": [
"This movie was sick in the best possible way.",
"This movie made me sick.",
"Not bad at all.",
"A masterpiece? Not really.",
"The acting was fine, but the story went nowhere.",
"I expected more.",
"The plot was unpredictable and surprisingly moving.",
"I would not say it is a bad movie.",
],
"expected_sentiment": [
"positive",
"negative",
"positive",
"negative",
"negative",
"negative",
"positive",
"positive_or_ambiguous",
],
}
)
label_map = {0: "negative", 1: "positive"}
edge_tfidf = tfidf_vectorizer.transform(edge_cases["review"])
edge_cases["tfidf_prediction"] = pd.Series(tfidf_clf.predict(edge_tfidf)).map(label_map)
edge_emb = embedding_model.encode(
edge_cases["review"].tolist(),
normalize_embeddings=True,
)
edge_cases["dense_prediction"] = pd.Series(dense_clf.predict(edge_emb)).map(label_map)
edge_casesExpected interpretation:
bad, sick, or masterpiece.