{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/dbamman/anlp25/blob/main/5.classification/Classification.ipynb)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "id": "fWui8kFbko1i"
   },
   "source": [
    "This notebook explores feature engineering for text classification, training a regularized logistic regression model for the binary classification task of predicting a movie's genre."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "jl-T_D3mko1o"
   },
   "outputs": [],
   "source": [
    "import json\n",
    "import operator\n",
    "from collections import Counter\n",
    "\n",
    "import nltk\n",
    "import pandas as pd\n",
    "from scipy import sparse\n",
    "from sklearn import linear_model\n",
    "from sklearn.model_selection import train_test_split\n",
    "from tqdm import tqdm\n",
    "\n",
    "nltk.download(\"punkt\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "HpkS30tUkzCw"
   },
   "outputs": [],
   "source": [
    "!wget https://github.com/dbamman/anlp25/raw/refs/heads/main/data/movie.metadata.tsv\n",
    "!wget https://github.com/dbamman/anlp25/raw/refs/heads/main/data/plot_summaries.txt"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "id": "nhKXxdKsko1p"
   },
   "source": [
    "Read movie metadata and return dictionary mapping genres to the set of movies that are tagged with that genre; print out the top 30 genres by frequency."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "xljgVR-1ko1q"
   },
   "outputs": [],
   "source": [
    "def read_metadata(metadata_filename):\n",
    "    metadata = pd.read_csv(metadata_filename, sep=\"\\t\", names=[\"movie_id\", \"_\", \"title\", \"year\", \"box_office\", \"_1\", \"_2\", \"_3\", \"json_genres\"])\n",
    "\n",
    "    # JSON genre looks like: {\"/m/01jfsb\": \"Thriller\", \"/m/0glj9q\": \"Erotic thriller\", \"/m/09blyk\": \"Psychological thriller\"}\n",
    "    # Convert to [\"Thriller\", \"Erotic thriller\", \"Psychological thriller\"]\n",
    "    metadata.loc[:, \"genres\"] = metadata.json_genres.apply(lambda x: list(json.loads(x).values()) if x is not None else [])\n",
    "    # Explode so that each row has one genre\n",
    "    movie_genres_metadata = metadata[[\"movie_id\", \"genres\"]].set_index(\"movie_id\").explode(\"genres\")\n",
    "\n",
    "    # map from genre -> set(movie_ids)\n",
    "    genre_movie_map = {genre: set(movie_ids) for genre, movie_ids in movie_genres_metadata.groupby(\"genres\").groups.items()}\n",
    "\n",
    "    # print out the 30 most frequent genres\n",
    "    genre_counts = sorted([(genre, len(movie_ids)) for genre, movie_ids in genre_movie_map.items()], key=lambda x: x[1], reverse=True)\n",
    "    for genre, count in genre_counts[:30]:\n",
    "        print(count, \"\\t\", genre)\n",
    "\n",
    "    return genre_movie_map"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "3_S0MqxXko1q"
   },
   "outputs": [],
   "source": [
    "metadata = read_metadata(\"movie.metadata.tsv\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "id": "D9ONP3uHko1r"
   },
   "source": [
    "Subset the metadata to just those movies that exclusively appear with one of two genres selected for binary classification."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "d7WcfTQxko1r"
   },
   "outputs": [],
   "source": [
    "def filter_for_genres(metadata, genre_a, genre_b):\n",
    "    # get all movies in genre_a and not in genre_b\n",
    "    # remember, our metadata is of type dict[str, set]\n",
    "    # so we can use set difference\n",
    "    genre_a_movies = metadata[genre_a] - metadata[genre_b]\n",
    "\n",
    "    # get all movies in genre_b but not in genre_a\n",
    "    genre_b_movies = metadata[genre_b] - metadata[genre_a]\n",
    "\n",
    "    labels = {\n",
    "        a_movie: 1 for a_movie in genre_a_movies\n",
    "    }\n",
    "    labels.update({\n",
    "        b_movie: 0 for b_movie in genre_b_movies\n",
    "    })\n",
    "\n",
    "    return labels"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "onOE0PXvko1r"
   },
   "outputs": [],
   "source": [
    "genre1 = \"Romantic comedy\"\n",
    "genre2 = \"Science Fiction\"\n",
    "\n",
    "labels = filter_for_genres(metadata, genre1, genre2)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "id": "wQ7vKBlLko1s"
   },
   "source": [
    "Read movie summary data and tokenize the descriptions of those movies that match for the two genres selected.  Return a list of tokenized summaries and a corresponding list of their binary labels."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "NbRcj5bJko1s"
   },
   "outputs": [],
   "source": [
    "def read_data(plot_filename, labels):\n",
    "\n",
    "    summaries = pd.read_csv(plot_filename, sep=\"\\t\", names=[\"movie_id\", \"summary\"])\n",
    "    # insert label\n",
    "    summaries.loc[:, \"label\"] = summaries.movie_id.apply(lambda x: labels[x] if x in labels else -1)\n",
    "    summaries = summaries.set_index(\"movie_id\")\n",
    "\n",
    "    def tokenize_and_process(text):\n",
    "        return nltk.word_tokenize(text.lower())\n",
    "\n",
    "    X = []\n",
    "    Y = []\n",
    "    for summary in tqdm(summaries.itertuples(), total=len(summaries)):\n",
    "        if summary.Index not in labels:\n",
    "            continue\n",
    "        X.append(tokenize_and_process(summary.summary))\n",
    "        Y.append(labels[summary.Index])\n",
    "\n",
    "    return X, Y"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "nyD3DJu4ko1s"
   },
   "outputs": [],
   "source": [
    "X, Y = read_data(\"plot_summaries.txt\", labels)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "id": "9ecCCb5cko1t"
   },
   "source": [
    "Split the data into training and validation sets (hold out 20% of the data for evaluation)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "UTYGq1M2ko1t"
   },
   "outputs": [],
   "source": [
    "trainX, devX, trainY, devY = train_test_split(X, Y, test_size=0.2, random_state=0)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "-E107Glbko1t"
   },
   "outputs": [],
   "source": [
    "def build_features(dataX, feature_functions):\n",
    "    \"\"\" Featurize the data according to the list of parameter feature_functions.\n",
    "\n",
    "    Each feature_fn takes in a list of tokens and outputs a dictionary mapping strings to numbers.\n",
    "    \"\"\"\n",
    "\n",
    "    data = []\n",
    "    for tokens in dataX:\n",
    "        feats = {}\n",
    "\n",
    "        for feature_fn in feature_functions:\n",
    "            feats.update(feature_fn(tokens))\n",
    "\n",
    "        data.append(feats)\n",
    "    return data"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "GM5V4sGkko1t"
   },
   "outputs": [],
   "source": [
    "def features_to_ids(data, feature_vocab):\n",
    "    \"\"\"\n",
    "    Convert a dictionary of feature names to a sparse representation that we can\n",
    "    fit in a scikit-learn model.  This is important because almost all feature\n",
    "    values will be 0 for most documents (note: why?), and we don't want to save\n",
    "    them all in memory.\n",
    "    \"\"\"\n",
    "    new_data = sparse.lil_matrix((len(data), len(feature_vocab)))\n",
    "    for idx, doc in enumerate(data):\n",
    "        for f in doc:\n",
    "            if f in feature_vocab:\n",
    "                new_data[idx,feature_vocab[f]]=doc[f]\n",
    "    return new_data"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "7NgJI-IIko1t"
   },
   "outputs": [],
   "source": [
    "def create_vocab(data, top_n=None):\n",
    "    \"\"\"\n",
    "    This helper function converts a dictionary of feature names to unique numerical ids.\n",
    "    top_n limits the features to only the n most frequent features observed in the training data\n",
    "    (in terms of the number of documents that contains it).\n",
    "    \"\"\"\n",
    "    counts = Counter()\n",
    "    for doc in data:\n",
    "        for feat in doc:\n",
    "            counts[feat] += 1\n",
    "\n",
    "    feature_vocab = {}\n",
    "\n",
    "    for idx, (k, v) in enumerate(counts.most_common(top_n)):\n",
    "        feature_vocab[k] = idx\n",
    "\n",
    "    return feature_vocab"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "4gBNbL2qko1u"
   },
   "outputs": [],
   "source": [
    "def pipeline(trainX, devX, trainY, devY, feature_functions):\n",
    "\n",
    "    \"\"\" This function evaluates a list of feature functions on the training/dev data arguments \"\"\"\n",
    "\n",
    "    trainX_feat = build_features(trainX, feature_functions)\n",
    "    devX_feat = build_features(devX, feature_functions)\n",
    "\n",
    "    # just create vocabulary from features in *training* data.\n",
    "    feature_vocab = create_vocab(trainX_feat, top_n=100000)\n",
    "\n",
    "    trainX_ids = features_to_ids(trainX_feat, feature_vocab)\n",
    "    devX_ids = features_to_ids(devX_feat, feature_vocab)\n",
    "\n",
    "    clf = linear_model.LogisticRegression(C=100, solver='lbfgs', penalty='l2', max_iter=10000)\n",
    "    clf.fit(trainX_ids, trainY)\n",
    "    print(\"Accuracy: %.3f\" % clf.score(devX_ids, devY))\n",
    "\n",
    "    return clf, feature_vocab"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "id": "0YvLsilKko1u"
   },
   "source": [
    "Let's create a simple dictionary-based feature: this feature value is set to 1 for a document whenever any word present that dictionary appears in that document (and 0 otherwise)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "72FhRgY_ko1u"
   },
   "outputs": [],
   "source": [
    "comedy_dictionary = set([\"comedy\", \"love\", \"date\"])\n",
    "scifi_dictionary = set([\"science\", \"ship\", \"alien\"])\n",
    "\n",
    "def dictionary_feature(tokens):\n",
    "    feats = {}\n",
    "    for word in tokens:\n",
    "        if word in comedy_dictionary:\n",
    "            feats[\"word_in_comedy_dictionary\"]=1\n",
    "        if word in scifi_dictionary:\n",
    "            feats[\"word_in_scifi_dictionary\"]=1\n",
    "    return feats"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "ZXkoFqtLko1u"
   },
   "outputs": [],
   "source": [
    "features = [dictionary_feature]\n",
    "clf, vocab = pipeline(trainX, devX, trainY, devY, features)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "id": "i6dAaI7nko1u"
   },
   "source": [
    "Is this accuracy good or bad?  We need to contextualize this performance against some baseline. A simple one to use is a *majority class* classifier: for every document in the test data, let's just predict whatever class appears the most frequently in the training data."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "Xdar1Q4Hko1u"
   },
   "outputs": [],
   "source": [
    "def majority_class(trainY, devY):\n",
    "    labelCounts = Counter()\n",
    "    for label in trainY:\n",
    "        labelCounts[label] += 1\n",
    "    majority_class = labelCounts.most_common(1)[0][0]\n",
    "\n",
    "    correct=0.\n",
    "    for label in devY:\n",
    "        if label == majority_class:\n",
    "            correct += 1\n",
    "\n",
    "    print(\"%s\\t%.3f\" % (majority_class, correct/len(devY)))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "z_mmX5ozko1u"
   },
   "outputs": [],
   "source": [
    "majority_class(trainY, devY)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "AuuhlUmuko1v"
   },
   "outputs": [],
   "source": [
    "def unigram_feature(tokens):\n",
    "    feats = {}\n",
    "    for word in tokens:\n",
    "        feats[\"UNIGRAM_%s\" % word] = 1\n",
    "    return feats"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "_qRDsDKtko1v"
   },
   "outputs": [],
   "source": [
    "features = [unigram_feature]\n",
    "clf, vocab = pipeline(trainX, devX, trainY, devY, features)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "id": "rBVV0jPMko1v"
   },
   "source": [
    "Let's print out the top 10 features with the strongest weights for each class"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "LOV5hlwOko1v"
   },
   "outputs": [],
   "source": [
    "def print_weights(clf, vocab, n=10):\n",
    "    weights = clf.coef_[0]\n",
    "    reverse_vocab = [None] * len(weights)\n",
    "    for k in vocab:\n",
    "        reverse_vocab[vocab[k]] = k\n",
    "\n",
    "    for feature, weight in sorted(zip(reverse_vocab, weights), key=lambda x: x[1])[:n]:\n",
    "        print(\"%.3f\\t%s\" % (weight, feature))\n",
    "\n",
    "    print()\n",
    "\n",
    "    for feature, weight in list(sorted(zip(reverse_vocab, weights), key=lambda x: x[1], reverse=True))[:n]:\n",
    "        print(\"%.3f\\t%s\" % (weight, feature))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "4d6quCTcko1v"
   },
   "outputs": [],
   "source": [
    "print_weights(clf, vocab, n=10)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "OrajSvowko1v"
   },
   "outputs": [],
   "source": [
    "def your_awesome_feature(tokens):\n",
    "    feats={}\n",
    "\n",
    "\n",
    "    return feats"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "-_ADadBDko1v"
   },
   "outputs": [],
   "source": [
    "features=[your_awesome_feature]\n",
    "pipeline(trainX, devX, trainY, devY, features)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "JukFpeP4ko1v"
   },
   "outputs": [],
   "source": []
  }
 ],
 "metadata": {
  "colab": {
   "provenance": []
  },
  "kernelspec": {
   "display_name": "Python 3 (ipykernel)",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.11.13"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 4
}
