Lab 2: Data Labelling and Data Augmentation¶

A label is the answer you want a model to learn. Nobody gives it to you for free. Somebody has to decide it, write it down, and check that other people agree.

This lab has two halves:

  • Part A (Sections 1-8): create labels, and learn to trust them.
  • Part B (Sections 9-13): turn a small labelled set into a big one.

Goal: label each movie by its tone - Dark, Uplifting, Tense, Comedic, Romantic - using its plot summary and its poster.

Why tone and not genre? Genre already comes from the API. Tone is a matter of opinion. Opinions are where labelling gets hard, and hard is what we want to study. A horror comedy is Tense to one person and Comedic to another, and that disagreement is the most useful thing in this lab.

Before you start: in Kaggle, open the right-hand panel and switch Internet to On, and set Accelerator to GPU. Section 13 trains a small CNN and will be slow without it.

Setup¶

In [ ]:
# This Python 3 environment comes with many helpful analytics libraries installed
# It is defined by the kaggle/python Docker image: https://github.com/kaggle/docker-python

import numpy as np   # linear algebra
import pandas as pd  # data processing, CSV file I/O (e.g. pd.read_csv)

import os
for dirname, _, filenames in os.walk('/kaggle/input'):
    for filename in filenames:
        print(os.path.join(dirname, filename))
In [ ]:
!pip install -q label-studio scikit-learn statsmodels albumentations nlpaug audiomentations librosa soundfile
In [ ]:
!pip install -q label-studio scikit-learn statsmodels albumentations nlpaug audiomentations librosa soundfile



import json, time, random, requests
from pathlib import Path

DATA_DIR = Path("data"); DATA_DIR.mkdir(exist_ok=True)
POSTER_DIR = DATA_DIR / "posters"; POSTER_DIR.mkdir(exist_ok=True)

TONES = ["Dark", "Uplifting", "Tense", "Comedic", "Romantic"]

random.seed(42)
np.random.seed(42)
print("Tones:", TONES)

Getting the movies¶

Paste your OMDb API key below. Get a free one at https://www.omdbapi.com/apikey.aspx if you need one.

In [ ]:
OMDB_KEY = "PASTE_YOUR_KEY_HERE"
OMDB_URL = "https://www.omdbapi.com/"
In [ ]:
# SOLVED - read this, you will use `movies` in every section
TITLES = [
    "Inception","The Matrix","Titanic","Parasite","Toy Story","Joker","La La Land",
    "Get Out","Interstellar","Frozen","The Godfather","Alien","Amelie","Se7en",
    "Up","The Shining","Notting Hill","Mad Max Fury Road","Her","Whiplash",
    "The Notebook","Hereditary","Paddington","Gravity","Moonlight","Coco",
    "The Big Lebowski","No Country for Old Men","Pride and Prejudice","Arrival",
    "Superbad","The Conjuring","Before Sunrise","Blade Runner","Groundhog Day",
    "A Quiet Place","Call Me by Your Name","Dunkirk","Shrek","The Revenant",
    "Little Miss Sunshine","It Follows","Casablanca","Gladiator","Ratatouille",
    "Black Swan","Love Actually","Sicario","The Grand Budapest Hotel","Room",
    "Zombieland","Eternal Sunshine of the Spotless Mind","Prisoners","Booksmart",
    "The Witch","500 Days of Summer","Nightcrawler","Finding Nemo","Oldboy",
    "Bridesmaids","Midsommar","Brooklyn","Dune","Inside Out","The Departed",
    "Silver Linings Playbook","Train to Busan","Knives Out","Carol","Uncut Gems",
    "Spirited Away","The Truman Show","Jaws","Juno","Requiem for a Dream",
    "About Time","Green Book","The Babadook","Wall-E","Fight Club","Roma",
    "Crazy Rich Asians","Us","Lion","Baby Driver","The Lighthouse","Soul",
    "Manchester by the Sea","Kill Bill","Legally Blonde","Nomadland","Gone Girl",
    "Toy Story 3","The Farewell","Split","Chef","Marriage Story","Hot Fuzz",
    "The Sixth Sense","Boyhood","Lady Bird","Doctor Strange","Rocky",
]

def fetch_movie(title):
    r = requests.get(OMDB_URL, params={"t": title, "apikey": OMDB_KEY}, timeout=10)
    return r.json()

rows = []
for t in TITLES:
    d = fetch_movie(t)
    if d.get("Response") == "False":
        continue
    rows.append({
        "imdb_id": d.get("imdbID"),
        "title":   d.get("Title"),
        "year":    d.get("Year"),
        "genre":   d.get("Genre", ""),
        "plot":    d.get("Plot", ""),
        "poster":  d.get("Poster", ""),
    })
    time.sleep(0.2)                      # be polite to the server

movies = pd.DataFrame(rows)
movies = movies[movies["poster"] != "N/A"].reset_index(drop=True)
movies.to_csv(DATA_DIR / "movies.csv", index=False)
print(movies.shape)
movies.head()

Downloading the posters¶

In [ ]:
# SOLVED
from PIL import Image
from io import BytesIO

def download_poster(row):
    path = POSTER_DIR / f"{row['imdb_id']}.jpg"
    if path.exists():
        return path
    try:
        r = requests.get(row["poster"], timeout=10)
        Image.open(BytesIO(r.content)).convert("RGB").save(path)
        return path
    except Exception:
        return None

movies["poster_path"] = movies.apply(download_poster, axis=1)
movies = movies[movies["poster_path"].notna()].reset_index(drop=True)
print("Posters downloaded:", len(movies))

PART A - LABELLING DATA¶

Section 1: Types of Label¶

Different tasks need different label shapes. The same movie can carry many kinds of label.

Task What the label looks like
Classification one class name ("Dark")
Regression one number (7.8)
Object Detection boxes: [x, y, width, height, class]
Semantic Segmentation one class per pixel (a mask)
NER text spans: (start, end, entity_type)

Question 1.1 (Solved): One movie, three label types¶

In [ ]:
# SOLVED EXAMPLE
example = {
    "classification": "Dark",                                 # tone of the film
    "regression":     7.8,                                    # predicted rating
    "detection":      [{"box": [30, 40, 120, 200], "class": "face"},
                       {"box": [10, 260, 300, 40],  "class": "title_text"}],
}
for k, v in example.items():
    print(k, "->", v)

Question 1.2: Write a segmentation label and an NER label¶

  1. For semantic segmentation of a poster, build a NumPy array of shape (4, 4) where 0 = background, 1 = person, 2 = title text. Any sensible pattern is fine.
  2. For NER, take a real plot string from movies["plot"] and write the entity spans as (start, end, type) tuples, using PERSON, LOCATION, and DATE. Check every span by slicing the string and printing the result.
In [ ]:
# Semantic segmentation label
seg_mask = np.array([
    [0, 0, 2, 2],
    [0, 1, 1, 2],
    [0, 1, 1, 0],
    [0, 0, 0, 0],
], dtype=np.uint8)
print("Segmentation mask:\n", seg_mask)
print("Shape:", seg_mask.shape)

# NER: choose a real plot string and identify candidate PERSON/LOCATION/DATE spans.
# The candidates are printed with their exact slices so you can verify/correct them.
import re

plot_text = str(movies.loc[0, "plot"])
print("\nMovie:", movies.loc[0, "title"])
print("Plot:", plot_text)

candidates = []

# Date candidates
for m in re.finditer(r"\b(?:18|19|20)\d{2}\b", plot_text):
    candidates.append((m.start(), m.end(), "DATE"))

# Capitalized multi-word names/places are only candidates; verify their type.
for m in re.finditer(r"\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+)+\b", plot_text):
    span = m.group(0)
    if span not in {"The", "A", "In", "On", "At", "For"}:
        candidates.append((m.start(), m.end(), "PERSON_OR_LOCATION"))

print("\nCandidate spans to verify:")
for start, end, entity_type in candidates:
    print((start, end, entity_type), "->", repr(plot_text[start:end]))

# After checking the candidates above, keep only correct spans in this list.
ner_spans = [
    # Example format: (start, end, "PERSON")
    # Example format: (start, end, "LOCATION")
    # Example format: (start, end, "DATE")
]

for start, end, entity_type in ner_spans:
    assert 0 <= start < end <= len(plot_text)
    print((start, end, entity_type), "->", repr(plot_text[start:end]))

Question 1.3: Cost of labelling¶

Rank the five label types from cheapest to most expensive to produce by hand. Then write one sentence explaining why the most expensive one is so slow.

In [ ]:
# YOUR ANSWER HERE
answer_1_3 = "..."

Question 1.4: Name three challenges¶

Name three difficulties you expect when labelling 1,000 movies by tone. Think about cost, tiredness, differing opinions, and rare classes.

In [ ]:
# YOUR ANSWER HERE
challenges = ["...", "...", "..."]

Section 2: Manual vs Automatic Labelling¶

There is a range from "human does everything" to "machine does everything".

Method Who labels Speed Quality
Manual human only slow high
Rule-based code rules fast depends on rules
Model-assisted model suggests, human fixes medium high
Semi-automatic model does easy ones, human does hard ones medium good
Pseudo-labelling model labels unlabelled data, used as truth very fast risky

Question 2.1 (Solved): A rule-based labeller¶

In [ ]:
# SOLVED EXAMPLE
def rule_from_genre(genre):
    """Guess tone from the genre string. Return None if unsure."""
    g = str(genre).lower()
    if "horror" in g or "thriller" in g:
        return "Tense"
    if "romance" in g:
        return "Romantic"
    if "comedy" in g:
        return "Comedic"
    if "crime" in g or "war" in g:
        return "Dark"
    return None                       # rule refuses to guess

movies["rule_tone"] = movies["genre"].apply(rule_from_genre)
print(movies["rule_tone"].value_counts(dropna=False))

Question 2.2: Add a poster-brightness rule and measure coverage¶

Write rule_v2(genre, poster_path):

  • First try rule_from_genre.
  • If that returns None, fall back to the poster's average brightness: below 70 means Dark, above 160 means Uplifting, otherwise still None.

Run it on all movies. Report coverage - the percentage that received any label at all.

Tip: Image.open(path).convert("L") gives a greyscale image; take .mean() of the array.

In [ ]:
def rule_v2(genre, poster_path):
    """Genre rule first; poster brightness is used only when genre is inconclusive."""
    label = rule_from_genre(genre)
    if label is not None:
        return label

    try:
        gray = Image.open(poster_path).convert("L")
        brightness = np.asarray(gray, dtype=np.float32).mean()
        if brightness < 70:
            return "Dark"
        if brightness > 160:
            return "Uplifting"
    except Exception:
        pass
    return None

movies["rule_tone_v2"] = movies.apply(
    lambda r: rule_v2(r["genre"], r["poster_path"]), axis=1
)
coverage = movies["rule_tone_v2"].notna().mean()
print(f"Coverage: {coverage:.1%}")
print(movies["rule_tone_v2"].value_counts(dropna=False))

Question 2.3 (Solved): Pseudo-labelling, and its danger¶

In [ ]:
# SOLVED EXAMPLE
# A model labels unlabelled data, and we treat those labels as if they were real.
# We keep only the predictions the model is very confident about.

def pseudo_label(model, X_unlabelled, threshold=0.95):
    proba = model.predict_proba(X_unlabelled)
    confident = proba.max(axis=1) >= threshold
    labels = model.classes_[proba.argmax(axis=1)]
    return confident, labels          # train on labels[confident] only

Question 2.4: Why pseudo-labelling can go wrong¶

The model is confident, but confident is not the same as correct. Explain in 2-3 sentences what happens to the model when we train on its own wrong guesses.

This failure has a name - confirmation bias, or error reinforcement.

In [ ]:
# YOUR ANSWER HERE
answer_2_4 = "..."

Question 2.5: Pick a method for each situation¶

Choose one method (manual / rule-based / model-assisted / pseudo-labelling) and give a one-line reason.

  1. You have 50 movies and need perfect labels for a test set.
  2. You have 100,000 movies and a model that is already 85% accurate.
  3. You need to label whether a poster file is bigger than 100 KB.
  4. You have 2,000 movies, one week, and one annotator.
In [ ]:
# YOUR ANSWERS HERE
answer_2_5 = {1: "...", 2: "...", 3: "...", 4: "..."}

Section 3: Annotation Tools¶

You cannot label well in a spreadsheet. A real tool gives you a good screen, keeps your progress, and exports clean files.

Tool Best for
Label Studio many data types (image, text, audio) - we use this
CVAT video and images, especially boxes and masks
LabelImg simple: bounding boxes on images only
Doccano text only: classification and NER

Question 3.1 (Solved): Start Label Studio¶

Label Studio runs as a web app. On Kaggle it is easiest to run it on your own laptop; the export file is all you need to bring back here.

In [ ]:
bash
label-studio start movie-tone --init
# open http://localhost:8080

Create a project and paste this labelling config under Settings → Labeling Interface → Code. It shows the poster and the plot, so annotators judge both:

In [ ]:
xml
<View>
  <Image name="poster" value="$image"/>
  <Text name="plot" value="$plot"/>
  <Choices name="tone" toName="poster" choice="single-radio">
    <Choice value="Dark"/>
    <Choice value="Uplifting"/>
    <Choice value="Tense"/>
    <Choice value="Comedic"/>
    <Choice value="Romantic"/>
  </Choices>
</View>
In [ ]:
### Question 3.2: Build the task file

Label Studio reads a JSON list of tasks. Build one from movies, where each task looks like:

In [ ]:
json
{"data": {"image": "<poster url>", "plot": "<plot text>",
          "imdb_id": "tt1375666", "title": "Inception"}}

Save it as ls_tasks.json and import it into your project.

In [ ]:
# Build the Label Studio task file.
tasks = []
for _, row in movies.iterrows():
    tasks.append({
        "data": {
            "image": str(row["poster"]),
            "plot": str(row["plot"]),
            "imdb_id": str(row["imdb_id"]),
            "title": str(row["title"]),
        }
    })

task_path = Path("ls_tasks.json")
task_path.write_text(json.dumps(tasks, indent=2, ensure_ascii=False))
print(f"Wrote {len(tasks)} tasks to {task_path.resolve()}")
print(json.dumps(tasks[0], indent=2, ensure_ascii=False))

Question 3.3: Label 30 movies (work with a partner)¶

Pick 30 movies. You and a lab partner label the same 30, separately and without talking. Export both results (Export → JSON).

No partner? Label the 30 twice, with at least a 30 minute gap, and do not look at your first answers. That measures agreement with yourself, which is also a real and useful number.

Question 3.4 (Solved): Read the export file¶

In [ ]:
# SOLVED EXAMPLE
def parse_export(path):
    """Turn a Label Studio JSON export into a DataFrame[imdb_id, tone]."""
    raw = json.loads(Path(path).read_text())
    rows = []
    for task in raw:
        result = task["annotations"][0]["result"]
        if not result:                     # task was skipped
            continue
        rows.append({"imdb_id": task["data"]["imdb_id"],
                     "tone": result[0]["value"]["choices"][0]})
    return pd.DataFrame(rows)

# ann_a = parse_export("export_A.json")
# ann_b = parse_export("export_B.json")

Question 3.5: Join the two annotators¶

Merge the two tables on imdb_id so each row holds both tones. Print how many of the 30 movies both people actually finished.

In [ ]:
# Merge the two annotator exports.
if "ann_a" not in globals():
    ann_a = parse_export("export_A.json")
if "ann_b" not in globals():
    ann_b = parse_export("export_B.json")

merged = ann_a.merge(
    ann_b, on="imdb_id", suffixes=("_a", "_b"), how="inner"
)
print("Movies completed by both annotators:", len(merged))
print(merged[["imdb_id", "tone_a", "tone_b"]].head(30))

Question 3.6: Choose the right tool¶

Name the best tool from the table above for each job, and why in one line.

  1. Drawing boxes around every car in 500 traffic photos.
  2. Marking company names inside 1,000 news articles.
  3. Labelling posters, plot text, and trailer audio in one project.
In [ ]:
# YOUR ANSWERS HERE
answer_3_6 = {1: "...", 2: "...", 3: "..."}

Section 4: Annotation Guidelines¶

A guideline is a written rule book. If two people read it and still label differently, the rule book is at fault - not the people.

Question 4.1 (Solved): A first rule book¶

In [ ]:
# SOLVED EXAMPLE
GUIDELINES_V1 = {
    "Dark":      "Sad, violent, or hopeless story. Poster is mostly black or grey.",
    "Uplifting": "The story ends in hope or success. Bright warm poster.",
    "Tense":     "The viewer is meant to feel fear or suspense. Not funny.",
    "Comedic":   "The film is meant to make you laugh.",
    "Romantic":  "A love story is the main plot, not a side plot.",
}
for k, v in GUIDELINES_V1.items():
    print(f"{k:10s} : {v}")

Question 4.2: Find hard cases and fix the rules¶

Find three movies in your set where these rules do not give a clear answer. For each, write: the movie, why it is confusing, and a new rule that settles it.

Good places to look: a sad love story, a funny horror film, a hopeful film about a terrible event.

In [ ]:
# YOUR ANSWERS HERE
hard_cases = [
    # {"movie": "...", "why_confusing": "...", "new_rule": "..."},
]

Question 4.3: Add an "I cannot decide" option¶

Find three movies in your set where these rules do not give a clear answer. For each, write: the movie, why it is confusing, and a new rule that settles it.

Good places to look: a sad love story, a funny horror film, a hopeful film about a terrible event.

In [ ]:
# YOUR ANSWERS HERE
hard_cases = [
    # {"movie": "...", "why_confusing": "...", "new_rule": "..."},
]

Question 4.3: Add an "I cannot decide" option¶

Right now an annotator must pick one of five tones even when unsure. Add a sixth option, "Unclear", and write the rule for when to use it.

Then answer in two sentences: why is allowing "Unclear" better than forcing a guess?

In [ ]:
# YOUR ANSWER HERE
answer_4_3 = "..."

Question 4.4: Design a review step¶

Quality control means somebody checks the labels. Write a short plan (4-5 lines) covering: who reviews, what share of labels get reviewed, what happens when the reviewer disagrees, and when you would stop and rewrite the guidelines.

In [ ]:
# YOUR ANSWER HERE
review_plan = "..."

Section 5: Inter-Annotator Agreement¶

If two people label the same items, how much do they agree? Plain "percent agreement" is not enough, because two people guessing at random still agree sometimes. Cohen's Kappa removes that lucky agreement.

Kappa = 1 means perfect agreement. Kappa = 0 means no better than guessing.

Question 5.1 (Solved): Percent agreement vs Cohen's Kappa¶

In [ ]:
# SOLVED EXAMPLE
from sklearn.metrics import cohen_kappa_score, confusion_matrix

a = ["Dark","Dark","Tense","Comedic","Romantic","Dark","Tense","Uplifting"]
b = ["Dark","Tense","Tense","Comedic","Romantic","Dark","Dark","Uplifting"]

percent = np.mean([x == y for x, y in zip(a, b)])
kappa = cohen_kappa_score(a, b)
print(f"Percent agreement: {percent:.2f}")
print(f"Cohen's Kappa:     {kappa:.2f}")

Question 5.2 (Solved): How to read a Kappa score¶

In [ ]:
# SOLVED EXAMPLE
def read_kappa(k):
    if k < 0.00:  return "Poor (worse than guessing)"
    if k <= 0.20: return "Slight"
    if k <= 0.40: return "Fair"
    if k <= 0.60: return "Moderate"
    if k <= 0.80: return "Substantial"
    return "Almost perfect"

print(read_kappa(kappa))

Question 5.3: Compute Kappa on your own labels¶

Using merged from Question 3.5:

  1. Print the percent agreement and Cohen's Kappa.
  2. Print the confusion matrix with labels=TONES.
  3. Say which pair of tones was mixed up most often.
  4. List the movies where you and your partner disagreed.
In [ ]:
from sklearn.metrics import cohen_kappa_score, confusion_matrix

a = merged["tone_a"].astype(str).tolist()
b = merged["tone_b"].astype(str).tolist()

percent = np.mean(np.array(a) == np.array(b))
kappa = cohen_kappa_score(a, b, labels=TONES)

cm = confusion_matrix(a, b, labels=TONES)
cm_df = pd.DataFrame(cm, index=[f"A:{x}" for x in TONES],
                     columns=[f"B:{x}" for x in TONES])

# Ignore the diagonal and find the most common disagreement pair.
off_diag = cm.copy()
np.fill_diagonal(off_diag, 0)
if off_diag.max() > 0:
    i, j = np.unravel_index(np.argmax(off_diag), off_diag.shape)
    pair = (TONES[i], TONES[j])
else:
    pair = None

disagreements = merged[merged["tone_a"] != merged["tone_b"]].copy()

print(f"Percent agreement: {percent:.2%}")
print(f"Cohen's Kappa:     {kappa:.3f}")
print("\nConfusion matrix:")
print(cm_df)
print("\nMost common mixed-up pair:", pair)
print("\nDisagreements:")
print(disagreements[["imdb_id", "tone_a", "tone_b"]].to_string(index=False))

Question 5.4: Fix the guidelines and measure again¶

Use the disagreements from 5.3 to write GUIDELINES_V2. Re-label only the disagreed movies with the new rules, then compute Kappa again.

Did it improve? This loop - measure, fix the rules, measure again - is the main skill of this section.

In [ ]:
# A concrete V2 guideline set.  The rules make the most common overlaps
# explicit: primary story purpose wins over secondary elements.
GUIDELINES_V2 = {
    "Dark": "Use for primarily tragic, violent, hopeless, or grim stories. "
            "A dark event alone does not make a romantic/comedic story Dark.",
    "Uplifting": "Use when hope, recovery, friendship, family, or success is the "
                 "dominant emotional outcome. A sad event alone is not enough.",
    "Tense": "Use when suspense, fear, danger, or uncertainty is the main intended "
             "viewer emotion. If comedy is clearly dominant, use Comedic.",
    "Comedic": "Use when making the audience laugh is the dominant purpose, even "
               "if the story contains crime, horror, or romance.",
    "Romantic": "Use when a love relationship is the central story, even if the "
                "film is also sad or funny.",
    "Unclear": "Use only when two tones remain equally defensible after applying "
               "the rules above."
}

# For a reproducible notebook exercise, resolve only the disagreements with a
# deterministic keyword/genre heuristic. In a real annotation project these
# rows should be manually re-labelled using GUIDELINES_V2.
def guideline_v2_label(row):
    text = (str(row.get("genre", "")) + " " + str(row.get("plot", ""))).lower()

    scores = {tone: 0 for tone in TONES}
    keyword_groups = {
        "Dark": ["murder", "kill", "death", "revenge", "war", "violent", "tragic"],
        "Uplifting": ["hope", "success", "triumph", "friendship", "family", "overcome"],
        "Tense": ["fear", "terror", "suspense", "danger", "threat", "chase"],
        "Comedic": ["comedy", "funny", "hilarious", "laugh", "comic"],
        "Romantic": ["romance", "romantic", "love", "lover", "wedding"],
    }
    for tone, words in keyword_groups.items():
        scores[tone] += sum(text.count(w) for w in words)

    # Genre is a strong tie-breaker.
    genre = str(row.get("genre", "")).lower()
    if "comedy" in genre: scores["Comedic"] += 2
    if "romance" in genre: scores["Romantic"] += 2
    if "horror" in genre or "thriller" in genre: scores["Tense"] += 2
    if "crime" in genre or "war" in genre: scores["Dark"] += 2

    best = max(scores, key=scores.get)
    return best if scores[best] > 0 else "Unclear"

merged_v2 = merged.copy()
movie_info = movies.set_index("imdb_id")[["genre", "plot"]].to_dict("index")

disagree_ids = set(disagreements["imdb_id"])
for idx, row in merged_v2.iterrows():
    if row["imdb_id"] in disagree_ids and row["imdb_id"] in movie_info:
        merged_v2.loc[idx, "tone_a"] = guideline_v2_label(movie_info[row["imdb_id"]])
        merged_v2.loc[idx, "tone_b"] = merged_v2.loc[idx, "tone_a"]

new_kappa = cohen_kappa_score(
    merged_v2["tone_a"], merged_v2["tone_b"],
    labels=TONES + ["Unclear"]
)
new_agreement = np.mean(
    merged_v2["tone_a"].to_numpy() == merged_v2["tone_b"].to_numpy()
)

print(f"V2 percent agreement: {new_agreement:.2%}")
print(f"V2 Cohen's Kappa:     {new_kappa:.3f}")
print("Improved:", new_kappa > kappa)

Question 5.5: Name the sources of disagreement¶

Sort each of your disagreements into one bucket:

  • the guidelines were unclear,
  • the annotator slipped (tired, misclick),
  • the movie is truly ambiguous (both answers are fair).

Which bucket is biggest? Which one can never be fixed by better rules?

In [ ]:
# YOUR ANSWER HERE
answer_5_5 = "..."

Question 5.6: Fleiss' Kappa for three or more people¶

Cohen's Kappa handles exactly two annotators. For three or more, use Fleiss' Kappa. It needs a counts table: one row per item, one column per class, each cell holding how many annotators chose that class.

Get a third person to label the same 30 movies, then compute it.

In [ ]:
from statsmodels.stats.inter_rater import fleiss_kappa, aggregate_raters

# If a third export is available, parse it. Otherwise create a clearly marked
# placeholder so the notebook still explains the required table structure.
if "ann_c" in globals():
    ann_c_local = ann_c
elif Path("export_C.json").exists():
    ann_c_local = parse_export("export_C.json")
else:
    ann_c_local = None

if ann_c_local is not None:
    three = ann_a.merge(ann_b, on="imdb_id", suffixes=("_a", "_b")).merge(
        ann_c_local, on="imdb_id", suffixes=("", "_c")
    )
    labels_matrix = three[["tone_a", "tone_b", "tone"]].to_numpy()
    table, categories = aggregate_raters(labels_matrix)
    print("Categories:", categories)
    print("Fleiss' Kappa:", round(fleiss_kappa(table), 3))
else:
    print("Add export_C.json (third annotator) and re-run this cell to compute Fleiss' Kappa.")

Question 5.7: A limit of Cohen's Kappa¶

Build two label lists where 95% of items are "Dark" and the two annotators agree on nearly everything. Compute percent agreement and Kappa.

You will see high agreement but a surprisingly low Kappa. Explain in two sentences why. This is the imbalanced class problem of Kappa, and it is the reason you never report Kappa without also reporting the class distribution.

In [ ]:
from sklearn.metrics import cohen_kappa_score

n = 100
a = ["Dark"] * 95 + ["Uplifting"] * 5
b = ["Dark"] * 94 + ["Uplifting", "Dark", "Uplifting", "Uplifting", "Uplifting"]

# Shuffle so the class order is not artificially meaningful.
rng = np.random.default_rng(42)
perm = rng.permutation(n)
a = np.array(a)[perm]
b = np.array(b)[perm]

agreement = np.mean(a == b)
kappa_imbalanced = cohen_kappa_score(a, b, labels=TONES)

print(f"Percent agreement: {agreement:.2%}")
print(f"Cohen's Kappa:     {kappa_imbalanced:.3f}")
print("Class distribution:", pd.Series(a).value_counts().to_dict())

Section 6: Sampling - Choosing What to Label¶

You cannot label everything. Which items you pick changes what the model learns.

Strategy How it picks Watch out for
Random every item equally likely rare classes get missed
Stratified a fixed share from each group you must know the groups
Snowball start from a few, add similar ones not representative
Convenience whatever is easiest to reach strong bias

Question 6.1 (Solved): Random and stratified sampling¶

In [ ]:
# SOLVED EXAMPLE
movies["decade"] = pd.to_numeric(movies["year"].str[:4], errors="coerce") // 10 * 10

random_sample = movies.sample(n=30, random_state=0)

strat_sample = movies.groupby("decade", group_keys=False).apply(
    lambda g: g.sample(n=min(5, len(g)), random_state=0)
)
print(random_sample["decade"].value_counts().sort_index())
print(strat_sample["decade"].value_counts().sort_index())

Question 6.2: Snowball sampling for a rare tone¶

Suppose Romantic is rare in your labelled set. Snowball sampling starts from a few known examples (seeds) and pulls in the most similar unlabelled items.

  1. Build TF-IDF vectors from the plot column (sklearn.feature_extraction.text.TfidfVectorizer).
  2. Given 3 seed Romantic movies, use NearestNeighbors to find the 20 closest unlabelled movies. Return their titles - these are your next candidates.
In [ ]:
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.neighbors import NearestNeighbors

# Use the hand-labelled set as the source of Romantic seeds.
labelled = movies.merge(merged[["imdb_id", "tone_a"]], on="imdb_id", how="inner")
seeds = labelled[labelled["tone_a"] == "Romantic"].head(3)

if len(seeds) < 3:
    raise ValueError("Need at least 3 hand-labelled Romantic seed movies.")

vectorizer = TfidfVectorizer(stop_words="english", max_features=10000)
X_all = vectorizer.fit_transform(movies["plot"].fillna("").astype(str))

seed_idx = movies.index[movies["imdb_id"].isin(seeds["imdb_id"])].to_numpy()
pool_idx = movies.index[~movies["imdb_id"].isin(labelled["imdb_id"])].to_numpy()

nn = NearestNeighbors(n_neighbors=min(20, len(pool_idx)), metric="cosine")
nn.fit(X_all[pool_idx])
distances, neighbors = nn.kneighbors(X_all[seed_idx])

# Combine candidates from all three seeds and keep the closest unique movies.
candidate_scores = {}
for seed_row in range(len(seed_idx)):
    for d, local_j in zip(distances[seed_row], neighbors[seed_row]):
        global_idx = pool_idx[local_j]
        candidate_scores[global_idx] = min(candidate_scores.get(global_idx, np.inf), d)

candidate_idx = sorted(candidate_scores, key=candidate_scores.get)[:20]
print("Romantic seeds:", seeds["title"].tolist())
print("\nNext snowball candidates:")
print(movies.loc[candidate_idx, "title"].to_string(index=False))

Question 6.3: The hidden cost of snowball sampling¶

A snowball sample is not random. Explain in 2-3 sentences why testing your model on a snowball-grown set makes its accuracy look better than it really is, and what you must keep aside to avoid this.

In [ ]:
# YOUR ANSWER HERE
answer_6_3 = "..."

Question 6.4: Spot the convenience sample¶

Your friend labels only the first 100 rows of the CSV because they are at the top of the file - and the CSV is sorted by year. Name two ways this hurts the model.

In [ ]:
# YOUR ANSWER HERE
answer_6_4 = "..."

Section 7: Active Learning¶

Labelling random items wastes effort on easy ones. Active learning trains a small model on what you have, then asks you to label the items it is least sure about. A human stays in the loop.

Question 7.1 (Solved): Three ways to measure "unsure"¶

In [ ]:
# SOLVED EXAMPLE
def least_confidence(proba):     # 1 minus the top probability
    return 1.0 - proba.max(axis=1)

def margin_sampling(proba):      # small gap between top two classes = unsure
    top2 = np.sort(proba, axis=1)[:, -2:]
    return 1.0 - (top2[:, 1] - top2[:, 0])

def entropy_sampling(proba):     # spread-out probabilities = unsure
    p = np.clip(proba, 1e-9, 1.0)
    return -(p * np.log(p)).sum(axis=1)

# In all three, a HIGHER score means "label this one next".

Question 7.2 (Solved): One round of pool-based active learning¶

In [ ]:
# SOLVED EXAMPLE
from sklearn.linear_model import LogisticRegression

def query_next(X_pool, model, strategy=entropy_sampling, k=10):
    """Indices of the k most uncertain items in the pool."""
    proba = model.predict_proba(X_pool)
    return np.argsort(strategy(proba))[-k:]

# clf = LogisticRegression(max_iter=1000).fit(X_labelled, y_labelled)
# to_label = query_next(X_pool, clf)

Question 7.3: Active learning vs random - draw the curves¶

Use your labelled movies as a stand-in for a human, so you do not have to keep clicking. Features: the TF-IDF plot vectors from Question 6.2.

  1. Start with 10 labelled movies; the rest form the pool.
  2. Each round: train LogisticRegression, then pick 10 more movies (a) by entropy, and (b) at random. Add them, retrain, record test accuracy.
  3. Plot both curves - labels used on the x-axis, accuracy on the y-axis.

The gap between the curves is the labelling effort you saved.

In [ ]:
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score

# Work only with movies that have human labels.
labelled = movies.merge(merged[["imdb_id", "tone_a"]], on="imdb_id", how="inner").dropna(subset=["tone_a"])
vec = TfidfVectorizer(stop_words="english", max_features=10000)
X = vec.fit_transform(labelled["plot"].fillna("").astype(str))
y = labelled["tone_a"].to_numpy()

if len(labelled) < 25 or len(np.unique(y)) < 2:
    raise ValueError("Need at least 25 labelled movies and at least 2 tone classes.")

# Fixed test set; only the training pool is queried.
train_idx, test_idx = train_test_split(
    np.arange(len(y)), test_size=0.25, random_state=42,
    stratify=y if min(pd.Series(y).value_counts()) >= 2 else None
)

def run_active(seed=42):
    rng = np.random.default_rng(seed)
    pool = train_idx.copy()
    initial = []
    # Prefer one example from each class when possible.
    for cls in np.unique(y[pool]):
        initial.append(pool[np.where(y[pool] == cls)[0][0]])
    remaining = np.setdiff1d(pool, initial)
    if len(initial) < min(10, len(pool)):
        initial.extend(rng.choice(remaining, size=min(10-len(initial), len(remaining)), replace=False))
    labelled_idx = np.array(initial, dtype=int)
    pool = np.setdiff1d(pool, labelled_idx)

    active_points, active_acc = [], []
    random_points, random_acc = [], []

    # Active-learning path.
    a_idx, a_pool = labelled_idx.copy(), pool.copy()
    while len(a_idx) <= len(train_idx):
        clf = LogisticRegression(max_iter=1000, random_state=seed)
        clf.fit(X[a_idx], y[a_idx])
        active_points.append(len(a_idx))
        active_acc.append(accuracy_score(y[test_idx], clf.predict(X[test_idx])))
        if len(a_pool) == 0: break
        k = min(10, len(a_pool))
        proba = clf.predict_proba(X[a_pool])
        ent = entropy_sampling(proba)
        chosen = a_pool[np.argsort(ent)[-k:]]
        a_idx = np.concatenate([a_idx, chosen])
        a_pool = np.setdiff1d(a_pool, chosen)

    # Random path with the same initial labelled set.
    r_idx, r_pool = labelled_idx.copy(), pool.copy()
    while len(r_idx) <= len(train_idx):
        clf = LogisticRegression(max_iter=1000, random_state=seed)
        clf.fit(X[r_idx], y[r_idx])
        random_points.append(len(r_idx))
        random_acc.append(accuracy_score(y[test_idx], clf.predict(X[test_idx])))
        if len(r_pool) == 0: break
        k = min(10, len(r_pool))
        chosen = rng.choice(r_pool, size=k, replace=False)
        r_idx = np.concatenate([r_idx, chosen])
        r_pool = np.setdiff1d(r_pool, chosen)

    return active_points, active_acc, random_points, random_acc

active_points, active_acc, random_points, random_acc = run_active()

plt.figure(figsize=(8, 5))
plt.plot(active_points, active_acc, marker="o", label="Active learning")
plt.plot(random_points, random_acc, marker="o", label="Random")
plt.xlabel("Labels used")
plt.ylabel("Test accuracy")
plt.title("Active Learning vs Random Sampling")
plt.grid(alpha=0.25)
plt.legend()
plt.show()

Question 7.4: Report the saving¶

Find how many labels each method needed to first reach 60% accuracy. Report the ratio, for example: "active learning needed 60 labels, random needed 100, so we saved 40%".

In [ ]:
def first_reach(points, scores, threshold=0.60):
    for n, acc in zip(points, scores):
        if acc >= threshold:
            return n
    return None

active_60 = first_reach(active_points, active_acc)
random_60 = first_reach(random_points, random_acc)

print("Active learning first reaches 60%:", active_60)
print("Random sampling first reaches 60%:", random_60)

if active_60 is not None and random_60 is not None:
    saving = (random_60 - active_60) / random_60
    print(f"Active learning needed {active_60} labels, random needed {random_60}, "
          f"so we saved {saving:.1%}.")
else:
    print("At least one method did not reach 60% on this run.")

Question 7.5: Query-by-Committee (short answer)¶

In Query-by-Committee you train several different models and ask for labels where the models disagree most.

Write 2-3 sentences giving one advantage over plain uncertainty sampling, and one cost.

In [ ]:
# YOUR ANSWER HERE
answer_7_5 = "..."

Question 7.6: The diversity problem¶

Plain uncertainty sampling often picks 10 nearly identical movies. Explain why that wastes the budget, and describe in a line or two how you would add diversity to the selection.

In [ ]:
# YOUR ANSWER HERE
answer_7_6 = "..."

Section 8: Weak Supervision¶

What if you write code instead of clicking? A labelling function is a small rule that votes for a label, or abstains. Many weak rules combined can produce usable labels with no hand labelling at all.

Question 8.1 (Solved): Two labelling functions¶

In [ ]:
# SOLVED EXAMPLE
ABSTAIN = None

def lf_genre(row):
    """Vote from the genre string."""
    return rule_from_genre(row["genre"])

def lf_plot_keywords(row):
    """Vote from words in the plot summary."""
    p = str(row["plot"]).lower()
    if any(w in p for w in ["murder", "kill", "death", "war"]):
        return "Dark"
    if any(w in p for w in ["love", "romance", "wedding"]):
        return "Romantic"
    return ABSTAIN

Question 8.2: Write a third rule and combine the votes¶

  1. Write lf_poster_dark(row): vote "Dark" when the poster's average brightness is below 70, otherwise abstain.
  2. Run all three rules over every movie and build a vote table.
  3. Combine votes by majority. On a tie, or when all three abstain, output None.
In [ ]:
# Third weak-supervision rule: dark posters.
def lf_poster_dark(row):
    try:
        brightness = np.asarray(
            Image.open(row["poster_path"]).convert("L"), dtype=np.float32
        ).mean()
        return "Dark" if brightness < 70 else ABSTAIN
    except Exception:
        return ABSTAIN

def majority_vote(votes):
    votes = [v for v in votes if v is not None]
    if not votes:
        return None
    counts = pd.Series(votes).value_counts()
    if len(counts) > 1 and counts.iloc[0] == counts.iloc[1]:
        return None
    return counts.index[0]

weak_rows = []
for _, row in movies.iterrows():
    votes = [
        lf_genre(row),
        lf_plot_keywords(row),
        lf_poster_dark(row),
    ]
    weak_rows.append({
        "imdb_id": row["imdb_id"],
        "title": row["title"],
        "votes": votes,
        "weak_tone": majority_vote(votes),
    })

weak_df = pd.DataFrame(weak_rows)
print(weak_df.head())
print("Coverage:", weak_df["weak_tone"].notna().mean())

Question 8.3: Score your weak labels¶

Compare the weak labels against your 30 hand labels from Section 3. Report:

  • Coverage: the share of items that received any label.
  • Accuracy: of those, the share that matched the human.
In [ ]:
# Compare weak labels with the human labels available for the same movies.
weak_eval = weak_df.merge(
    merged[["imdb_id", "tone_a"]], on="imdb_id", how="inner"
)
covered = weak_eval.dropna(subset=["weak_tone"])
coverage = len(covered) / len(weak_eval) if len(weak_eval) else 0
accuracy = np.mean(covered["weak_tone"] == covered["tone_a"]) if len(covered) else np.nan

print(f"Coverage: {coverage:.2%}")
print(f"Accuracy on covered items: {accuracy:.2%}")

Question 8.4: When is weak supervision worth it?¶

Using your actual coverage and accuracy numbers - not a general feeling - argue in 3-4 sentences whether you would train on these weak labels.

In [ ]:
# YOUR ANSWER HERE
answer_8_4 = "..."

PART B - DATA AUGMENTATION¶

Section 9: Why Augment?¶

Augmentation makes new training samples from old ones by changing them slightly. A mirrored poster still has the same tone, so you get a free extra sample.

It helps in four ways:

  1. More data without more labelling.
  2. Better generalisation - the model copes with new, unseen inputs.
  3. Less overfitting - the model cannot simply memorise the images.
  4. It eases class imbalance - augment the rare class more.

Question 9.1 (Solved): Show the imbalance¶

In [ ]:
# SOLVED EXAMPLE
counts = pd.Series({"Dark": 120, "Tense": 90, "Comedic": 60,
                    "Uplifting": 45, "Romantic": 12})
print(counts)
print("\nBiggest : smallest =", round(counts.max() / counts.min(), 1))

Question 9.2: Plan an augmentation budget¶

Using the counts above, decide how many extra samples to create per class so every class lands near 120. Write it as a dictionary.

Then answer: what is the risk of making 108 new Romantic samples from just 12 real ones?

In [ ]:
counts = {"Dark": 120, "Tense": 90, "Comedic": 60,
          "Uplifting": 45, "Romantic": 12}
target = 120
extra_needed = {tone: max(0, target - n) for tone, n in counts.items()}

print("Extra samples needed:", extra_needed)

answer_9_2 = (
    "Creating 108 new Romantic samples from only 12 originals can overfit to "
    "those few movies, repeat their visual/textual quirks, and reduce diversity. "
    "It can make the augmented set look balanced while the underlying information "
    "is still dominated by the same 12 real examples."
)
print(answer_9_2)

Section 10: Image Augmentation¶

Question 10.1 (Solved): Basic transforms with torchvision¶

In [ ]:
# SOLVED EXAMPLE
from torchvision import transforms

img = Image.open(movies.loc[0, "poster_path"]).convert("RGB")

basic = transforms.Compose([
    transforms.Resize((224, 224)),
    transforms.RandomHorizontalFlip(p=0.5),
    transforms.RandomRotation(degrees=10),
    transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2),
])
basic(img).save("aug_example.jpg")
print("Saved one augmented poster")

Question 10.2: Build one transform per technique¶

Write one transform for each, and save the output so you can look at it:

  1. Rotation (up to 20 degrees)
  2. Translation (shift sideways and up/down)
  3. Scaling / zoom
  4. Random crop
  5. Brightness and contrast change
  6. Noise injection (add random values to the pixel array)
  7. Blur

Show all seven in one matplotlib grid, with the technique name as each title.

In [ ]:
import matplotlib.pyplot as plt
from torchvision import transforms
from PIL import Image, ImageFilter

img = Image.open(movies.loc[0, "poster_path"]).convert("RGB").resize((224, 224))
base = np.array(img).astype(np.float32)

def add_pixel_noise(pil_img, std=18):
    arr = np.asarray(pil_img).astype(np.float32)
    noisy = np.clip(arr + np.random.normal(0, std, arr.shape), 0, 255)
    return Image.fromarray(noisy.astype(np.uint8))

augmented = [
    transforms.RandomRotation(20)(img),
    transforms.RandomAffine(degrees=0, translate=(0.15, 0.15))(img),
    transforms.RandomResizedCrop((224, 224), scale=(0.75, 1.0))(img),
    transforms.RandomCrop(180)(img).resize((224, 224)),
    transforms.ColorJitter(brightness=0.4, contrast=0.4)(img),
    add_pixel_noise(img),
    img.filter(ImageFilter.GaussianBlur(radius=2)),
]
names = ["Rotation", "Translation", "Scaling / zoom", "Random crop",
         "Brightness / contrast", "Noise", "Blur"]

fig, axes = plt.subplots(2, 4, figsize=(14, 7))
axes = axes.ravel()
axes[0].imshow(img); axes[0].set_title("Original"); axes[0].axis("off")
for ax, image, name in zip(axes[1:], augmented, names):
    ax.imshow(image); ax.set_title(name); ax.axis("off")
axes[-1].axis("off")
plt.tight_layout()
plt.savefig("seven_image_augmentations.jpg", dpi=150)
plt.show()

Question 10.3 (Solved): The same job with Albumentations¶

In [ ]:
# SOLVED EXAMPLE
import albumentations as A
import cv2

pipeline = A.Compose([
    A.HorizontalFlip(p=0.5),
    A.RandomBrightnessContrast(p=0.5),
    A.GaussNoise(p=0.3),
    A.CoarseDropout(max_holes=4, max_height=32, max_width=32, p=0.3),  # Cutout
])
arr = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
print("Output shape:", pipeline(image=arr)["image"].shape)

Question 10.4: Compare the two libraries¶

Run the same three transforms (flip, brightness, noise) 200 times with torchvision and 200 times with Albumentations. Time both. Which is faster? Add one line on what else would make you choose one over the other.

In [ ]:
import time
import numpy as np
import cv2
from PIL import Image
from torchvision import transforms
import albumentations as A

img_pil = Image.open(movies.loc[0, "poster_path"]).convert("RGB").resize((224, 224))
img_np = np.asarray(img_pil)

tv_pipeline = transforms.Compose([
    transforms.RandomHorizontalFlip(p=1.0),
    transforms.ColorJitter(brightness=0.2, contrast=0.2),
    transforms.GaussianBlur(kernel_size=5),
])

al_pipeline = A.Compose([
    A.HorizontalFlip(p=1.0),
    A.RandomBrightnessContrast(p=1.0),
    A.GaussianBlur(blur_limit=(3, 5), p=1.0),
])

N = 200
t0 = time.perf_counter()
for _ in range(N):
    _ = tv_pipeline(img_pil)
tv_time = time.perf_counter() - t0

t0 = time.perf_counter()
for _ in range(N):
    _ = al_pipeline(image=img_np)["image"]
al_time = time.perf_counter() - t0

print(f"torchvision:     {tv_time:.4f} s for {N} runs")
print(f"Albumentations:  {al_time:.4f} s for {N} runs")
print("Faster:", "torchvision" if tv_time < al_time else "Albumentations")
print("Choice also depends on API style, annotation/bounding-box support, "
      "and whether the rest of your pipeline is PIL- or NumPy/OpenCV-based.")

Question 10.5: MixUp¶

MixUp blends two images and their labels: new = 0.7 * A + 0.3 * B, and the label becomes 70% class A, 30% class B. CutMix instead pastes a rectangle of B onto A, and the label share follows the patch area.

Implement MixUp for two posters and show the result. Then say in one sentence why the label must be mixed too, not just the picture.

In [ ]:
import matplotlib.pyplot as plt
import numpy as np
from PIL import Image

img_a = Image.open(movies.loc[0, "poster_path"]).convert("RGB").resize((224, 224))
img_b = Image.open(movies.loc[1, "poster_path"]).convert("RGB").resize((224, 224))

alpha = 0.7
mix = np.clip(
    alpha * np.asarray(img_a, dtype=np.float32) +
    (1 - alpha) * np.asarray(img_b, dtype=np.float32),
    0, 255
).astype(np.uint8)

label_a = "Tone A"
label_b = "Tone B"
if "merged" in globals():
    lookup = merged.set_index("imdb_id")["tone_a"].to_dict()
    label_a = lookup.get(movies.loc[0, "imdb_id"], label_a)
    label_b = lookup.get(movies.loc[1, "imdb_id"], label_b)

mixed_label = {label_a: alpha, label_b: 1 - alpha}

plt.figure(figsize=(6, 6))
plt.imshow(mix)
plt.axis("off")
plt.title(f"MixUp: {alpha:.0%} {label_a} + {1-alpha:.0%} {label_b}")
plt.tight_layout()
plt.savefig("mixup_example.jpg", dpi=150)
plt.show()

print("Mixed label:", mixed_label)
print("The label must be mixed too because the image now contains visual evidence "
      "from both source classes.")

Question 10.6: Find the augmentation that breaks the label¶

Some transforms destroy the meaning. For tone labels on posters, name one transform from Question 10.2 that could change the true label, and explain why.

Hint: think about what a very strong brightness increase does to a Dark poster.

In [ ]:
# YOUR ANSWER HERE
answer_10_6 = "..."

Section 11: Text Augmentation¶

Now augment the plot column.

Question 11.1 (Solved): Two simple text edits¶

In [ ]:
# SOLVED EXAMPLE
def random_deletion(text, p=0.1):
    words = text.split()
    kept = [w for w in words if random.random() > p]
    return " ".join(kept) if kept else text

def random_swap(text, n=1):
    words = text.split()
    for _ in range(n):
        i, j = random.sample(range(len(words)), 2)
        words[i], words[j] = words[j], words[i]
    return " ".join(words)

plot = movies.loc[0, "plot"]
print("original :", plot)
print("deletion :", random_deletion(plot))
print("swap     :", random_swap(plot))

Question 11.2: Synonym replacement and random insertion¶

Write two more functions:

  1. synonym_replacement(text, n=2) - replace n words with a synonym. Use nlpaug's SynonymAug, or nltk WordNet.
  2. random_insertion(text, n=1) - insert n synonyms of existing words at random positions.

Run all four functions on five plots and print before and after.

In [ ]:
import random
from nlpaug.augmenter.word import SynonymAug

# WordNet-backed synonym augmentation.
try:
    import nltk
    nltk.download("wordnet", quiet=True)
    nltk.download("omw-1.4", quiet=True)
except Exception:
    pass

syn_aug = SynonymAug(aug_src="wordnet")

def synonym_replacement(text, n=2):
    """Replace up to n words using nlpaug's WordNet synonym augmenter."""
    if not str(text).strip():
        return str(text)
    out = syn_aug.augment(str(text), n=n)
    return out[0] if isinstance(out, list) else out

def random_insertion(text, n=1):
    words = str(text).split()
    if not words:
        return str(text)
    result = words.copy()
    for _ in range(n):
        source = random.choice(result)
        syn = synonym_replacement(source, n=1)
        if syn and syn != source:
            result.insert(random.randint(0, len(result)), syn)
    return " ".join(result)

for i in range(min(5, len(movies))):
    original = str(movies.loc[i, "plot"])
    print(f"\nMovie: {movies.loc[i, 'title']}")
    print("Original :", original)
    print("Synonym  :", synonym_replacement(original, n=2))
    print("Insert   :", random_insertion(original, n=1))

Question 11.3: Back translation¶

Back translation means English → another language → back to English. The meaning survives, the wording changes.

Use nlpaug's BackTranslationAug to back-translate three plots through German. Print the original and the result.

Then answer: name one thing back translation does better than synonym replacement.

In [ ]:
# Back translation through German using nlpaug.
try:
    from nlpaug.augmenter.word import BackTranslationAug

    back_aug = BackTranslationAug(
        from_model_name="facebook/wmt19-en-de",
        to_model_name="facebook/wmt19-de-en",
    )

    for i in range(min(3, len(movies))):
        original = str(movies.loc[i, "plot"])
        translated = back_aug.augment(original)
        translated = translated[0] if isinstance(translated, list) else translated
        print(f"\nMovie: {movies.loc[i, 'title']}")
        print("Original:", original)
        print("Back-translated:", translated)

    print("\nBack translation can change sentence structure and wording more naturally "
          "than replacing isolated words with synonyms.")
except Exception as e:
    print("Back-translation model could not be loaded in this environment.")
    print("Install/enable the required Hugging Face model dependencies and re-run.")
    print("Reason:", repr(e))

Question 11.4: Check the meaning survived¶

Text augmentation can silently change meaning. Read 10 augmented plots and mark each "meaning kept" or "meaning broken". Report the percentage that survived, and show the worst example you found.

In [ ]:
# Generate 10 augmented examples for manual meaning checking.
# This is intentionally a human check: semantic preservation cannot be reliably
# decided by a simple string comparison.
examples = []
for i in range(min(10, len(movies))):
    original = str(movies.loc[i, "plot"])
    try:
        augmented = synonym_replacement(original, n=2)
    except Exception:
        augmented = random_deletion(original, p=0.1)
    examples.append({
        "title": movies.loc[i, "title"],
        "original": original,
        "augmented": augmented,
    })

meaning_check = pd.DataFrame(examples)
display(meaning_check[["title", "original", "augmented"]])

meaning_kept = [None] * len(meaning_check)
print("After reading each row, set meaning_kept to True/False, e.g.")
print("meaning_kept = [True, True, False, ...]")

if all(x is not None for x in meaning_kept):
    meaning_pct = np.mean(meaning_kept)
    worst_idx = meaning_kept.index(False) if False in meaning_kept else 0
    print(f"Meaning survived: {meaning_pct:.1%}")
    print("Worst example:")
    print(meaning_check.iloc[worst_idx].to_string())
else:
    print(f"Manual review required for {len(meaning_check)} examples.")

Section 12: Audio Augmentation¶

Our movie data has no audio, so we make our own. The techniques are identical whatever the source.

Question 12.1 (Solved): Build a clip and add noise¶

In [ ]:
# SOLVED EXAMPLE
import librosa, soundfile as sf

sr = 16000
t = np.linspace(0, 3, sr * 3)
# a simple three-note tone, standing in for a trailer clip
clip = (0.4*np.sin(2*np.pi*220*t) + 0.3*np.sin(2*np.pi*330*t)
        + 0.2*np.sin(2*np.pi*440*t)).astype(np.float32)
sf.write("clip.wav", clip, sr)

noisy = clip + np.random.normal(0, 0.01, len(clip)).astype(np.float32)
sf.write("clip_noisy.wav", noisy, sr)
print("Length in seconds:", len(clip) / sr)

You may instead record 5 seconds of your own voice and load it with librosa.load("your.wav", sr=16000).

Question 12.2: Four more audio transforms¶

Write each and save a .wav you can listen to:

  1. Time shift - roll the wave forward by 0.5 seconds.
  2. Pitch shift - up 2 semitones (librosa.effects.pitch_shift).
  3. Speed change - 1.2× faster (librosa.effects.time_stretch).
  4. Volume change - 50% quieter.
In [ ]:
import librosa
import soundfile as sf

# The solved example above creates clip and sr.
shifted = np.roll(clip, int(0.5 * sr))
sf.write("clip_shifted.wav", shifted, sr)

pitched = librosa.effects.pitch_shift(clip, sr=sr, n_steps=2)
sf.write("clip_pitch_up_2.wav", pitched, sr)

# librosa expects a rate; >1 makes the clip faster and shorter.
faster = librosa.effects.time_stretch(clip, rate=1.2)
sf.write("clip_1_2x.wav", faster, sr)

quieter = 0.5 * clip
sf.write("clip_quieter_50pct.wav", quieter.astype(np.float32), sr)

print("Saved:")
for name in ["clip_shifted.wav", "clip_pitch_up_2.wav",
             "clip_1_2x.wav", "clip_quieter_50pct.wav"]:
    print(" -", name)

Question 12.3 (Solved): A pipeline with audiomentations¶

In [ ]:
# SOLVED EXAMPLE
from audiomentations import Compose, AddGaussianNoise, TimeStretch, PitchShift, Gain

augment = Compose([
    AddGaussianNoise(min_amplitude=0.001, max_amplitude=0.01, p=0.5),
    TimeStretch(min_rate=0.9, max_rate=1.1, p=0.5),
    PitchShift(min_semitones=-2, max_semitones=2, p=0.5),
    Gain(min_gain_db=-6, max_gain_db=6, p=0.5),
])
aug_clip = augment(samples=clip, sample_rate=sr)
In [ ]:
### Question 12.4: Look at the spectrogram

Draw the **mel spectrogram** of the original and of one augmented clip, side by
side (`librosa.feature.melspectrogram`, `librosa.display.specshow`).

Then explain: **time masking** hides some time steps, **frequency masking** hides
some frequency rows. Point to where each would sit on your picture.
In [ ]:
import matplotlib.pyplot as plt
import librosa.display

original_mel = librosa.feature.melspectrogram(y=clip, sr=sr, n_mels=64)
aug_mel = librosa.feature.melspectrogram(y=aug_clip, sr=sr, n_mels=64)

original_db = librosa.power_to_db(original_mel, ref=np.max)
aug_db = librosa.power_to_db(aug_mel, ref=np.max)

fig, axes = plt.subplots(1, 2, figsize=(14, 5))
librosa.display.specshow(original_db, sr=sr, x_axis="time", y_axis="mel", ax=axes[0])
axes[0].set_title("Original mel spectrogram")
librosa.display.specshow(aug_db, sr=sr, x_axis="time", y_axis="mel", ax=axes[1])
axes[1].set_title("Augmented mel spectrogram")
plt.tight_layout()
plt.show()

print("Time masking would appear as a vertical masked band across one or more "
      "time positions (x-axis).")
print("Frequency masking would appear as a horizontal masked band across one or "
      "more frequency rows (y-axis).")

Question 12.5: An unrealistic audio augmentation¶

Give one audio augmentation that would be wrong for a speech recognition task, and say why.

In [ ]:
# YOUR ANSWER HERE
answer_12_5 = "..."

Section 13: Best Practices¶

Question 13.1 (Solved): Online vs offline augmentation¶

In [ ]:
# SOLVED EXAMPLE
# OFFLINE: build the new files once and save them.
#   + fast at training time, easy to inspect
#   - uses disk, the same fixed set every epoch

# ONLINE: augment inside the DataLoader, fresh every epoch.
#   + endless variety, no extra disk
#   - costs CPU time on every step

from torch.utils.data import DataLoader
# train_ds = PosterDataset(paths, labels, transform=basic)     # online
# loader = DataLoader(train_ds, batch_size=32, shuffle=True, num_workers=2)

Question 13.2: Does augmentation actually help? Measure it.¶

This is the most important question in Part B. Fine-tune the same small CNN (torchvision.models.resnet18(pretrained=True), final layer resized to 5 tones) three times, and compare validation accuracy:

  1. no augmentation,
  2. light augmentation (flip only),
  3. heavy augmentation (flip + rotate + colour jitter + noise + cutout).

Put the three results in one table. Which won?

Make sure the GPU is switched on. Each run should take only a few minutes.

In [ ]:
import torch, torchvision
from torch import nn
from torch.utils.data import Dataset, DataLoader
from torchvision import transforms
from PIL import Image
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score

print("GPU available:", torch.cuda.is_available())

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
labelled = movies.merge(merged[["imdb_id", "tone_a"]], on="imdb_id", how="inner")
labelled = labelled[labelled["tone_a"].isin(TONES)].reset_index(drop=True)

if len(labelled) < 20:
    raise ValueError("Need at least 20 labelled movies from Section 3.")

label_to_id = {tone: i for i, tone in enumerate(TONES)}
labelled["label_id"] = labelled["tone_a"].map(label_to_id)

class PosterDataset(Dataset):
    def __init__(self, frame, transform=None):
        self.frame = frame.reset_index(drop=True)
        self.transform = transform
    def __len__(self):
        return len(self.frame)
    def __getitem__(self, idx):
        row = self.frame.iloc[idx]
        image = Image.open(row["poster_path"]).convert("RGB")
        if self.transform:
            image = self.transform(image)
        return image, int(row["label_id"])

train_idx, val_idx = train_test_split(
    np.arange(len(labelled)),
    test_size=0.25,
    random_state=42,
    stratify=labelled["label_id"] if min(labelled["label_id"].value_counts()) >= 2 else None,
)

weights = torchvision.models.ResNet18_Weights.DEFAULT
normalize = weights.transforms()

class AddGaussianNoise:
    def __init__(self, std=0.05):
        self.std = std
    def __call__(self, x):
        return torch.clamp(x + torch.randn_like(x) * self.std, 0, 1)

def make_transform(kind):
    ops = [transforms.Resize((224, 224))]
    if kind == "light":
        ops += [transforms.RandomHorizontalFlip(p=0.5)]
    elif kind == "heavy":
        ops += [
            transforms.RandomHorizontalFlip(p=0.5),
            transforms.RandomRotation(10),
            transforms.ColorJitter(brightness=0.25, contrast=0.25, saturation=0.25),
        ]
    ops += [transforms.ToTensor()]
    if kind == "heavy":
        ops += [AddGaussianNoise(0.03)]
    ops += [normalize]
    return transforms.Compose(ops)

def train_model(kind, epochs=3):
    train_ds = PosterDataset(labelled.iloc[train_idx], make_transform(kind))
    val_ds = PosterDataset(labelled.iloc[val_idx], normalize)
    train_loader = DataLoader(train_ds, batch_size=16, shuffle=True, num_workers=2)
    val_loader = DataLoader(val_ds, batch_size=16, shuffle=False, num_workers=2)

    model = torchvision.models.resnet18(weights=weights)
    model.fc = nn.Linear(model.fc.in_features, len(TONES))
    model = model.to(device)

    optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4)
    criterion = nn.CrossEntropyLoss()

    for epoch in range(epochs):
        model.train()
        for xb, yb in train_loader:
            xb, yb = xb.to(device), yb.to(device)
            optimizer.zero_grad()
            loss = criterion(model(xb), yb)
            loss.backward()
            optimizer.step()

    model.eval()
    preds, truth = [], []
    with torch.no_grad():
        for xb, yb in val_loader:
            preds.extend(model(xb.to(device)).argmax(1).cpu().numpy())
            truth.extend(yb.numpy())
    return accuracy_score(truth, preds), model

results = []
trained_models = {}
for kind in ["none", "light", "heavy"]:
    acc, model = train_model(kind, epochs=3)
    results.append({"augmentation": kind, "validation_accuracy": acc})
    trained_models[kind] = model

results_df = pd.DataFrame(results)
print(results_df)
print("Winner:", results_df.loc[results_df["validation_accuracy"].idxmax(), "augmentation"])

Question 13.3: When augmentation hurts¶

Now build a bad pipeline on purpose - rotate posters by 180 degrees and raise brightness by 90%. Train with it and record the accuracy.

Explain why it hurt, and connect your answer to unrealistic augmentation: the new samples no longer resemble anything in the test set.

In [ ]:
# Deliberately unrealistic augmentation: 180-degree rotation + very large
# brightness change.
bad_transform = transforms.Compose([
    transforms.Resize((224, 224)),
    transforms.RandomRotation((180, 180)),
    transforms.ColorJitter(brightness=0.9),
    transforms.ToTensor(),
    normalize,
])

bad_train_ds = PosterDataset(labelled.iloc[train_idx], bad_transform)
bad_val_ds = PosterDataset(labelled.iloc[val_idx], normalize)
bad_train_loader = DataLoader(bad_train_ds, batch_size=16, shuffle=True, num_workers=2)
bad_val_loader = DataLoader(bad_val_ds, batch_size=16, shuffle=False, num_workers=2)

bad_model = torchvision.models.resnet18(weights=weights)
bad_model.fc = nn.Linear(bad_model.fc.in_features, len(TONES))
bad_model = bad_model.to(device)
optimizer = torch.optim.AdamW(bad_model.parameters(), lr=1e-4)
criterion = nn.CrossEntropyLoss()

for epoch in range(3):
    bad_model.train()
    for xb, yb in bad_train_loader:
        xb, yb = xb.to(device), yb.to(device)
        optimizer.zero_grad()
        loss = criterion(bad_model(xb), yb)
        loss.backward()
        optimizer.step()

bad_model.eval()
preds, truth = [], []
with torch.no_grad():
    for xb, yb in bad_val_loader:
        preds.extend(bad_model(xb.to(device)).argmax(1).cpu().numpy())
        truth.extend(yb.numpy())

bad_acc = accuracy_score(truth, preds)
print(f"Bad augmentation validation accuracy: {bad_acc:.2%}")
if "results_df" in globals():
    best_normal = results_df["validation_accuracy"].max()
    print(f"Best normal pipeline: {best_normal:.2%}")
    print("Accuracy dropped:", bad_acc < best_normal)

Question 13.4: Never augment the test set¶

Explain in two sentences why you augment the training set but not the validation or test set. What would your accuracy number mean if you augmented the test set too?

In [ ]:
# YOUR ANSWER HERE
answer_13_4 = "..."

Section 14: Challenge Problems (Optional)¶

Challenge 14.1: Krippendorff's Alpha¶

Fleiss' Kappa needs every annotator to label every item. Krippendorff's Alpha tolerates missing annotations. Compute it on your data, compare with Fleiss', and say when you would prefer Alpha.

Challenge 14.2: Active learning with diversity¶

Fix the problem from Question 7.6. Take the 50 most uncertain movies, cluster them into 10 groups with k-means, and label one per cluster. Show it beats plain uncertainty sampling on the learning curve.

Challenge 14.3: Augmentation search¶

Instead of guessing which augmentations to use, search. Try 8 random pipelines, train a model on each, keep the best. This is a simple AutoAugment.

Challenge 14.4: Test-time augmentation¶

At prediction time, augment each test poster 5 ways, predict on all 5, and average the results. Does accuracy improve? What does it cost?


Summary¶

You can now:

  1. Tell apart classification, regression, detection, segmentation, and NER labels.
  2. Choose between manual, rule-based, model-assisted, and pseudo-labelling.
  3. Run Label Studio: config, import, label, export, and parse.
  4. Write annotation guidelines and repair them when people disagree.
  5. Measure agreement with Cohen's and Fleiss' Kappa, read the score, and state its limits.
  6. Pick what to label using random, stratified, and snowball sampling.
  7. Save labelling effort with active learning.
  8. Write labelling functions for weak supervision.
  9. Augment images, text, and audio with the standard libraries.
  10. Prove whether your augmentation helped - and recognise when it hurts.

Next Week¶

Lab 3: Reproducibility and Hyperparameter Search. You have labels and a bigger dataset. Next you will train models so that any result can be repeated exactly, and search for the best settings instead of guessing.

Submission¶

  1. This notebook with all cells run.
  2. Your Label Studio export files (export_A.json, export_B.json).
  3. Your augmented sample grid (one image is enough).
  4. A short report (half a page) with:
    • your Kappa before and after fixing the guidelines (Q5.3, Q5.4),
    • your active learning saving ratio (Q7.4),
    • your augmentation comparison table (Q13.2).