ELOC CYBERBACK TO THE BROWSER LAB ↗

AI CLASS / BEGINNER STUDENT NOTEBOOK

Teach a computer to read movie reviews.

Examples → word groups → numbers → train → check → change settings → build an app. TikTok and photo chapters are optional.

Start in Colab
  1. Download the notebook above.
  2. Open Google Colab, sign in with a Google account, and choose File → Upload notebook (or the Upload tab on the welcome screen).
  3. Select the downloaded .ipynb file. Run cells in order with their ▶ buttons, reading the short explanation above each one.

You do not need to install Python on your computer. Use a CPU runtime for the main movie-review project. If you restart the Colab runtime, run the earlier main-section cells again before continuing.

To copy instead, create a new Colab notebook and use Copy code on each cell below. This page lets you read and copy; Python runs in Colab. The browser exercises need no account.

50 cells · 23 Python cells · Beginner edition, matching the student slides

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.

In [ ]: Python · cell 1
%pip -q install "datasets>=3,<5" "scikit-learn>=1.3,<2" "pandas>=2,<3" "gradio>=4,<7" "huggingface_hub>=0.24,<2" pyarrow requests pillow

2. 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.

In [ ]: Python · cell 2
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.

In [ ]: Python · cell 3
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.

In [ ]: Python · cell 4
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.

In [ ]: Python · cell 5
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.

In [ ]: Python · cell 6
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.

In [ ]: Python · cell 7
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.

In [ ]: Python · cell 8
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?

In [ ]: Python · cell 9
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 as 0.1, keeps the weights more constrained.
  • A larger C, such as 10, 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.

In [ ]: Python · cell 10
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.

In [ ]: Python · cell 11
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.

In [ ]: Python · cell 12
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?

In [ ]: Python · cell 13
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.

In [ ]: Python · cell 14
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.

In [ ]: Python · cell 15
# 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.

In [ ]: Python · cell 16
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.

In [ ]: Python · cell 17
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.

In [ ]: Python · cell 18
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.

In [ ]: Python · cell 19
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.

In [ ]: Python · cell 20
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.

In [ ]: Python · cell 21
%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"]))
In [ ]: Python · cell 22
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.

In [ ]: Python · cell 23
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

Record the dataset used, choices you changed, and one result you would want to verify with more data.