AI Lab — Teach a Computer to Read Movie Reviews
Student notebook — beginner edition
Main lab: build a positive/negative movie-review classifier, then run experiments that change one setting at a time.
Optional extensions: TikTok caption popularity and asking questions about photos.
What you need: a browser, a Google account, and Google Colab. You do not need to install Python on your computer.
How to work: Run each code cell with the ▶ button. Read the short explanation above it. When you see a setting in CAPITALS or an experiment list, you may change it and rerun the cell. Colab runs earlier cells' results in memory, so if you restart the runtime, use Runtime → Run all for the main section.
Route: examples → n-grams → text into numbers → train → measure → change settings → build app. The TikTok and visual chapters can be skipped without breaking the movie-review project.
1. Install the tools (run once)
A library is code written by other programmers that we can reuse. Colab has Python running on Google's computers; %pip installs these libraries into that temporary Colab session.
%pip -q install "datasets>=3,<5" "scikit-learn>=1.3,<2" "pandas>=2,<3" "gradio>=4,<7" "huggingface_hub>=0.24,<2" pyarrow requests pillow2. Open the training examples
We will use a public Rotten Tomatoes review dataset.
Each example has text, such as a short review, and a label, which is the answer we want the computer to learn: 0 = negative, 1 = positive.
The dataset has three separate groups. Train is what the model learns from; validation helps us compare versions; test is set aside for one last check. The same example should not be in all three groups.
from datasets import load_dataset
from IPython.display import display
import pandas as pd
reviews = load_dataset("cornell-movie-review-data/rotten_tomatoes")
train = reviews["train"].to_pandas()
valid = reviews["validation"].to_pandas()
test = reviews["test"].to_pandas()
print("Training examples:", len(train))
print("Validation examples:", len(valid))
print("Test examples:", len(test))
print("0 = negative, 1 = positive")
display(train.head(5))3. What is an n-gram?
Python cannot hand raw English directly to the simple model we are using. First, we break sentences into smaller units called tokens. For this lab the tokens are mostly words.
Consider “this movie is not good”. We can describe it using:
- Unigrams (
n=1):this,movie,is,not,good— one word at a time. - Bigrams (
n=2):this movie,movie is,is not,not good— pairs of neighboring words. - Trigrams (
n=3):this movie is,movie is not,is not good— groups of three neighboring words.
The groups overlap. An n-gram is not a special kind of AI model; it is a way of choosing what text patterns to count.
In ngram_range=(1, 2), 1 is the shortest group and 2 is the longest. That means the model will see single words and adjacent pairs. Larger groups may help with phrases, but they also make many more possible features and may rarely repeat in the data.
from sklearn.feature_extraction.text import CountVectorizer
example = ["this movie is not good"]
for setting in [(1, 1), (1, 2), (1, 3)]:
counter = CountVectorizer(ngram_range=setting)
counter.fit(example)
print(setting, "->", list(counter.get_feature_names_out()))
# Change example[0] to your own phrase and rerun.Try it
Change example to "this movie is not good", then "this movie is good". Which phrases appear in one sentence but not the other? Think about why grouping nearby words could matter when one word (like “not”) changes the meaning of another.
4. What is TF-IDF?
The model needs numbers instead of words. One simple choice is to count words. TF-IDF is a more careful count: it gives less weight to words that appear in nearly every review, and more weight to terms that help distinguish one review from another.
- TF = how often a word or phrase shows up in this text.
- IDF = how rare it is across the collection of training texts.
max_features= limit on how many different words/phrases we keep.min_df= minimum number of training reviews in which a word/phrase must occur to be included.
TF-IDF does not understand the sentence the way a person does. It builds a table of numerical features that the model can use.
from sklearn.feature_extraction.text import TfidfVectorizer
little_reviews = [
"the movie was funny and sweet",
"the movie was slow and dull",
"the ending was funny"
]
vectorizer = TfidfVectorizer(ngram_range=(1, 1))
number_table = vectorizer.fit_transform(little_reviews)
print("Columns:", list(vectorizer.get_feature_names_out()))
display(pd.DataFrame(number_table.toarray(),
columns=vectorizer.get_feature_names_out()).round(2))5. What does a model learn?
A classifier is a program that predicts which category an example belongs to. Here it predicts positive or negative. We will use logistic regression. The name sounds like ordinary linear regression, but the tasks are different:
- Linear regression is commonly used to predict a numerical amount, such as a price.
- Logistic regression is used here to estimate the chance that a review belongs to the positive class.
With our TF-IDF table, logistic regression learns which numerical features tend to appear in positive or negative training reviews and how to combine them. The fit() method is the training step.
A pipeline connects TF-IDF and the classifier, so every new review gets processed the same way as the training reviews.
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score
model = Pipeline([
("text_to_numbers", TfidfVectorizer(
ngram_range=(1, 2), # words + word pairs
min_df=2, # keep terms in at least 2 training reviews
max_features=10000 # keep at most 10,000 text features
)),
("classifier", LogisticRegression(
C=1.0, # regularization setting
max_iter=1000 # upper limit on fitting steps
))
])
model.fit(train["text"], train["label"])
valid_guess = model.predict(valid["text"])
print("Validation accuracy:", round(accuracy_score(valid["label"], valid_guess), 3))6. Test the model on sentences you write
predict() chooses the label. predict_proba() provides model-estimated probabilities for each label. The numbers may look precise, but they are not a guarantee that the answer is correct. Try positive, negative, mixed, and sarcastic sentences.
my_reviews = [
"I loved the music and the acting.",
"The story was boring and the ending was terrible.",
"The acting was great, but I did not like the story."
]
labels = {0: "Negative", 1: "Positive"}
for text, guess, chances in zip(
my_reviews,
model.predict(my_reviews),
model.predict_proba(my_reviews)
):
print("Review:", text)
print("Prediction:", labels[int(guess)],
"| Estimated positive probability:", round(float(chances[1]), 2))
print()7. How do we know if it is working?
Accuracy is the fraction of validation examples predicted correctly. If a model gets 8 of 10 reviews correct, accuracy is 80%.
A baseline is a simple comparison. Our baseline always predicts the most common training label without reading the text. We will also look at a confusion matrix, which counts correct and incorrect predictions by category.
from sklearn.dummy import DummyClassifier
from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay
import matplotlib.pyplot as plt
baseline = DummyClassifier(strategy="most_frequent")
baseline.fit(train[["text"]], train["label"])
baseline_guess = baseline.predict(valid[["text"]])
print("Baseline accuracy:", round(accuracy_score(valid["label"], baseline_guess), 3))
print("Our model accuracy:", round(accuracy_score(valid["label"], valid_guess), 3))
ConfusionMatrixDisplay.from_predictions(
valid["label"], valid_guess,
display_labels=["Negative", "Positive"],
cmap="Blues", colorbar=False
)
plt.title("Validation predictions")
plt.show()8. Experiment: change the n-grams
Here we'll hold the dataset and classifier steady and change only the n-gram range. Each row fits a new model and tests it on the same validation reviews. You can add or remove a setting. Write down which one does best on this particular dataset.
ngram_results = []
for gram_setting in [(1, 1), (1, 2), (1, 3)]:
attempt = Pipeline([
("text_to_numbers", TfidfVectorizer(
ngram_range=gram_setting, min_df=2, max_features=10000
)),
("classifier", LogisticRegression(C=1.0, max_iter=1000))
])
attempt.fit(train["text"], train["label"])
score = accuracy_score(valid["label"], attempt.predict(valid["text"]))
ngram_results.append({"n-grams": str(gram_setting), "validation_accuracy": round(score, 3)})
display(pd.DataFrame(ngram_results))9. Experiment: vocabulary size and minimum frequency
When we increase max_features, the model can use more distinct words or phrases. When we increase min_df, we remove rare terms seen in too few training examples.
For example, min_df=5 removes a phrase if it appears in only one, two, three or four training reviews. Try the combinations below. How do training time and validation accuracy change?
vocab_results = []
for vocab_size, minimum_reviews in [(2000, 2), (10000, 2), (10000, 5)]:
attempt = Pipeline([
("text_to_numbers", TfidfVectorizer(
ngram_range=(1, 2),
max_features=vocab_size,
min_df=minimum_reviews
)),
("classifier", LogisticRegression(C=1.0, max_iter=1000))
])
attempt.fit(train["text"], train["label"])
score = accuracy_score(valid["label"], attempt.predict(valid["text"]))
vocab_results.append({"max_features": vocab_size,
"min_df": minimum_reviews,
"validation_accuracy": round(score, 3)})
display(pd.DataFrame(vocab_results))10. Experiment: change the classifier's C setting
C is a logistic regression setting that controls how strongly the model's learned feature weights are restricted.
- A smaller
C, such as0.1, keeps the weights more constrained. - A larger
C, such as10, allows the weights to become larger if the training examples support them.
Neither is always better. Compare results rather than assuming one value will win. max_iter is mainly a limit on optimization steps, not the number of training examples.
c_results = []
for c_value in [0.1, 1.0, 10.0]:
attempt = Pipeline([
("text_to_numbers", TfidfVectorizer(
ngram_range=(1, 2), min_df=2, max_features=10000
)),
("classifier", LogisticRegression(C=c_value, max_iter=1000))
])
attempt.fit(train["text"], train["label"])
score = accuracy_score(valid["label"], attempt.predict(valid["text"]))
c_results.append({"C": c_value, "validation_accuracy": round(score, 3)})
display(pd.DataFrame(c_results))11. Experiment: train on fewer or more examples
Sampling means choosing which examples we give the model. More examples can show the model more varieties of language, although quality and variety also matter. We'll draw reproducible samples from the same training data and keep validation unchanged.
size_results = []
for sample_size in [200, 1000, len(train)]:
small_train = train.sample(n=sample_size, random_state=42)
attempt = Pipeline([
("text_to_numbers", TfidfVectorizer(
ngram_range=(1, 2), min_df=2, max_features=10000
)),
("classifier", LogisticRegression(C=1.0, max_iter=1000))
])
attempt.fit(small_train["text"], small_train["label"])
score = accuracy_score(valid["label"], attempt.predict(valid["text"]))
size_results.append({"training_examples": sample_size,
"validation_accuracy": round(score, 3)})
display(pd.DataFrame(size_results))12. Experiment: change the information the model receives
A feature is information available to the model. So far our features come from all the words in a review. What if the model can see only the first 40 characters? We'll fit the same kind of model twice and use the same examples and labels in both runs. Notice that the shortened text might remove important information near the end of a sentence.
feature_results = []
for version, make_text in [
("whole review", lambda s: s),
("first 40 characters", lambda s: s.str.slice(0, 40))
]:
attempt = Pipeline([
("text_to_numbers", TfidfVectorizer(
ngram_range=(1, 2), min_df=2, max_features=10000
)),
("classifier", LogisticRegression(C=1.0, max_iter=1000))
])
attempt.fit(make_text(train["text"]), train["label"])
guesses = attempt.predict(make_text(valid["text"]))
feature_results.append({"available_text": version,
"validation_accuracy": round(accuracy_score(valid["label"], guesses), 3)})
display(pd.DataFrame(feature_results))13. Optional experiment: change the question we ask
The target is the answer the model is trained to predict. So far that answer was positive or negative sentiment. Below, the review text stays the same, but the target becomes does this review contain the letters “movie”? The label comes directly from the review text, so it should be a simpler task than understanding sentiment. This does not make it a useful predictor—it illustrates how changing the label changes the job.
If you instead wanted to predict whether a headline was clickbait, what new labels would you need?
mention_train = train["text"].str.contains("movie", case=False, regex=False).astype(int)
mention_valid = valid["text"].str.contains("movie", case=False, regex=False).astype(int)
word_model = Pipeline([
("text_to_numbers", TfidfVectorizer(
ngram_range=(1, 2), min_df=2, max_features=10000
)),
("classifier", LogisticRegression(C=1.0, max_iter=1000))
])
word_model.fit(train["text"], mention_train)
print('Target: does the review contain "movie"?')
print("Validation accuracy:", round(accuracy_score(
mention_valid, word_model.predict(valid["text"])), 3))14. Try another model (optional)
Naive Bayes is another classifier that uses text counts or TF-IDF features. It estimates how likely terms are to appear within each class using a simpler set of assumptions. It is often fast and useful as a comparison. In the code below, only the final model changes.
from sklearn.naive_bayes import MultinomialNB
bayes_model = Pipeline([
("text_to_numbers", TfidfVectorizer(
ngram_range=(1, 2), min_df=2, max_features=10000
)),
("classifier", MultinomialNB(alpha=1.0))
])
bayes_model.fit(train["text"], train["label"])
print("Naive Bayes validation accuracy:", round(accuracy_score(
valid["label"], bayes_model.predict(valid["text"])), 3))15. Finish the main project: choose your settings
Look at your experiment tables. Decide which n-gram setting, vocabulary size, and C value you want. Write them below. You may use ideas from the experiments, but only fit on the training set here. The test set is held back until now.
Evaluation design is how we decide whether a model works on new examples. Reusing the test group repeatedly to choose settings makes it less useful as an independent check.
# Choose settings based on your VALIDATION experiments:
FINAL_NGRAM = (1, 2)
FINAL_VOCAB = 10000
FINAL_MIN_DF = 2
FINAL_C = 1.0
final_model = Pipeline([
("text_to_numbers", TfidfVectorizer(
ngram_range=FINAL_NGRAM,
min_df=FINAL_MIN_DF,
max_features=FINAL_VOCAB
)),
("classifier", LogisticRegression(C=FINAL_C, max_iter=1000))
])
final_model.fit(train["text"], train["label"])
print("Final TEST accuracy:", round(accuracy_score(
test["label"], final_model.predict(test["text"])), 3))16. Make an app you can show someone
Gradio creates a text box and displays the output of your trained model. The link works while your Colab session is running. Do not submit personal or private data to a public demo link.
import gradio as gr
def review_predictor(text):
if not text.strip():
return {"Negative": 0.5, "Positive": 0.5}
scores = final_model.predict_proba([text])[0]
return {"Negative": float(scores[0]),
"Positive": float(scores[1])}
gradio_app = gr.Interface(
fn=review_predictor,
inputs=gr.Textbox(lines=3, label="Write a review"),
outputs=gr.Label(num_top_classes=2),
title="My movie-review classifier"
)
gradio_app.launch(share=True)Report your experiment
Choose one change you made. Record: the setting before and after; your validation scores; one example the model got wrong; and what you would try next. A difference of a few decimal points on one split may be small; rerunning the experiment with other data could tell you whether it is consistent.
You can stop here. The next two parts connect the same ideas to the class's TikTok and visual AI proposals.
Optional A — Could text help predict which TikTok videos get many views?
Dataset: datasocial/tiktok-5.6B-videos. The complete dataset is hundreds of gigabytes. This extension downloads one small 2018 monthly Parquet file rather than the entire collection.
X (features): caption alone, or caption plus on-screen text.
y (target): whether the video has views at or above the 80th percentile of training views in this selected slice.
We are not forecasting a new creator's future reach with any guarantee. Views were recorded at different times, and the text excludes many factors that affect popularity. Do not use views, likes, shares, or engagement_rate as inputs to this model. This dataset is licensed CC BY-NC 4.0.
This extension needs an internet download and could fail if the remote file changes or Colab blocks access.
from huggingface_hub import hf_hub_download
import numpy as np
monthly_path = hf_hub_download(
repo_id="datasocial/tiktok-5.6B-videos",
repo_type="dataset",
filename="data/2018-01.parquet"
)
tt = pd.read_parquet(monthly_path)
print("Rows before filtering:", len(tt))
print("Available columns:", list(tt.columns))
display(tt[["caption", "views"]].head())Prepare a small, usable table
Select videos with captions and valid view counts. We'll sort by posting date, use older rows to train, and later rows to validate/test. This time-based split is an alternative to randomly mixing records. It is not perfect: creators and other patterns may still appear in multiple groups.
tt["posted_at"] = pd.to_datetime(tt["posted_at"], utc=True, errors="coerce")
tt["views"] = pd.to_numeric(tt["views"], errors="coerce")
tt["caption"] = tt["caption"].fillna("").astype(str)
tt = tt[(tt["posted_at"].notna()) & (tt["views"] > 0) &
(tt["caption"].str.len() >= 5)].copy()
def screen_to_text(item):
if isinstance(item, (list, tuple, np.ndarray)):
return " ".join(str(piece) for piece in item if piece is not None)
return ""
if "on_screen_text" in tt.columns:
tt["screen_text"] = tt["on_screen_text"].apply(screen_to_text)
else:
tt["screen_text"] = ""
tt["both_texts"] = tt["caption"] + " " + tt["screen_text"]
tt = tt.sample(n=min(12000, len(tt)), random_state=42)
tt = tt.sort_values("posted_at").reset_index(drop=True)
assert len(tt) >= 100, "Too few usable videos in this slice."
cut1, cut2 = int(0.6*len(tt)), int(0.8*len(tt))
tr, va, te = tt.iloc[:cut1].copy(), tt.iloc[cut1:cut2].copy(), tt.iloc[cut2:].copy()
VIEW_QUANTILE = 0.80 # experiment with 0.70 or 0.90
view_threshold = tr["views"].quantile(VIEW_QUANTILE)
for part in (tr, va, te):
part["popular"] = (part["views"] >= view_threshold).astype(int)
print("Popular = at least", round(view_threshold), "observed views")
print("Training/validation/test sizes:", len(tr), len(va), len(te))Try two kinds of text
The two runs below use exactly the same videos, popularity labels, and algorithm. We change the features: first just captions; then captions with any available on-screen text.
Because popular videos may be a minority, the table includes precision (of predicted popular videos, what fraction truly met the threshold) and recall (of all threshold-reaching videos, what fraction were found), alongside accuracy.
from sklearn.metrics import precision_score, recall_score
results = []
for title, column in [("caption", "caption"),
("caption + on-screen", "both_texts")]:
candidate = Pipeline([
("text_to_numbers", TfidfVectorizer(
ngram_range=(1, 2), min_df=2, max_features=10000
)),
("classifier", LogisticRegression(
C=1.0, class_weight="balanced", max_iter=1000
))
])
candidate.fit(tr[column], tr["popular"])
guesses = candidate.predict(va[column])
results.append({
"features": title,
"accuracy": round(accuracy_score(va["popular"], guesses), 3),
"precision": round(precision_score(va["popular"], guesses, zero_division=0), 3),
"recall": round(recall_score(va["popular"], guesses, zero_division=0), 3)
})
display(pd.DataFrame(results))
print("Fraction of popular videos in validation:", round(va["popular"].mean(), 3))TikTok discussion (no additional code required)
Try changing VIEW_QUANTILE from 0.80 to 0.90 and rerun the two TikTok training cells. The label now means something different. How does that change the fraction of positive examples and the scores? What might captions tell you about a video, and what information about the video is unavailable to this text model?
You may also inspect a few videos the classifier gets wrong. If you want to claim a result would work for modern TikTok, you would need more recent, representative, consistently measured examples.
Optional B — Ask a question about an image
Dataset: fawern/visual-question-answering-coco, a collection of 217 examples containing an image URL, question, and answer.
This is visual question answering (VQA). The input contains an image and a question. That's different from ordinary image classification, where the input is an image and the output is a category, like “mug.”
For this small dataset, we will use a pretrained model rather than train a vision model from scratch. The weights were learned before this lab. Large model downloads may take time on a CPU-only runtime.
from IPython.display import display
from PIL import Image
from io import BytesIO
import requests
pictures = load_dataset("fawern/visual-question-answering-coco", split="train")
print("Number of examples:", len(pictures))
print("Columns:", pictures.column_names)
item = pictures[0]
print("Question:", item["question"])
print("Reference answer:", item["answer"])
image = None
for url in [item["image_url"].replace("http://", "https://"), item["image_url"]]:
try:
response = requests.get(url, timeout=20)
response.raise_for_status()
image = Image.open(BytesIO(response.content)).convert("RGB")
break
except Exception as error:
print("Photo download did not work:", str(error)[:100])
if image is not None:
display(image)
else:
print("Use a photo you upload in the app below if this host is unavailable.")Give an existing model an image and a question
This model is named BLIP. It has already been trained elsewhere. Our code downloads it and asks it to respond to an image. You can change the question or give it a different photo; this does not update its learned weights.
%pip -q install "transformers>=4.46,<5" accelerate
from transformers import pipeline
vqa_model = pipeline(
task="visual-question-answering",
model="Salesforce/blip-vqa-base",
device=-1 # CPU
)
if image is not None:
print(vqa_model(image=image, question=item["question"]))def answer_photo(photo, question):
if photo is None or not question.strip():
return "Add an image and a question."
result = vqa_model(image=photo, question=question)
return result[0]["answer"]
photo_app = gr.Interface(
fn=answer_photo,
inputs=[gr.Image(type="pil", sources=["upload", "webcam"], label="Your image"),
gr.Textbox(value="What object is in this picture?", label="Question")],
outputs=gr.Textbox(label="Model answer"),
title="Ask about a photograph"
)
photo_app.launch(share=True)Separate optional experiment: image classification
A classifier predicts categories from a photo without receiving a question. google/vit-base-patch16-224 is a pretrained image classifier with its own set of categories. It is not trained in this class and is not trained on the 217 VQA examples we inspected.
photo_classifier = pipeline(
task="image-classification",
model="google/vit-base-patch16-224",
device=-1
)
def classify_photo(photo):
if photo is None:
return {}
answers = photo_classifier(photo, top_k=5)
return {a["label"]: float(a["score"]) for a in answers}
gr.Interface(
fn=classify_photo,
inputs=gr.Image(type="pil", sources=["upload", "webcam"]),
outputs=gr.Label(num_top_classes=5),
title="Image category classifier"
).launch(share=True)Sources and further practice
- Google Colab
- Movie review dataset
- TF-IDF documentation
- Logistic regression documentation
- TikTok video dataset (CC BY-NC 4.0)
- COCO VQA subset
- BLIP VQA model
Record the dataset used, choices you changed, and one result you would want to verify with more data.