ISF DP Computer Science

Classification Features #

In this lab, you will build a machine learning system that classifies text messages as either spam or as legitimate (called ham, since spam is fake ham—a corny joke that became the industry standard term). You will progress through three approaches: hand-written rules, hand-designed features with machine-learned weights, and finally a bag-of-words model that treats every word in the vocabulary as a feature. Along the way you will discover why each step improves on the last.


[0] Setup #

💻 Go to your dpcs folder and, if you haven’t already, create a new folder for this unit.

cd ~/desktop/dpcs/
mkdir unit06_machine_learning
cd unit06_machine_learning

💻 Clone your repo. This will copy it onto your computer. Be sure to replace yourgithubusername with your actual username.

git clone https://github.com/isf-dp-cs/lab_classification_features_yourgithubusername
💻 In the Terminal, type the following command to open the lab folder.
cd lab_classification_features_yourgithubusername
💻 Enter the Poetry Shell to start the lab.
poetry shell
💻 Install required packages.
poetry install
💻 Install the command-line tool for the lab.
pipx install . -e
👾 💬

If you already installed it incorrectly, force it to update using:

pipx install . -e --force


[1] How classifiers are evaluated #

Your lab already contains a naive classifier that predicts every message is ham. On our dataset that turns out to be 86% accurate—because 86% of messages are ham. But this accuracy is hollow: not a single spam message is caught. We need better ways to measure what a classifier is actually doing.

💻 Evaluate the naive classifier:
spam models.manual.ManualClassifier
 1  ============================================================
 2  DATASET
 3  ============================================================
 4
 5    Total messages: 5572
 6    ham :  4825  (86.6%)
 7    spam:   747  (13.4%)
 8
 9    train: 3900  (70%)   test: 1672  (30%)
10
11  ============================================================
12  RESULTS: ManualClassifier
13  ============================================================
14
15                precision     recall         f1
16    ham             0.866      1.000      0.928
17    spam            0.000      0.000      0.000
18
19    average f1                            0.464
20
21  Confusion matrix:
22                     pred ham  pred spam
23  actual ham               1448          0
24  actual spam               224          0

[2] Writing rules by hand #

Now look at what the classifier actually does.

💻 Open models/manual.py and find the predict_one method:

def predict_one(self, message):
    return "ham"

This always returns "ham"—which is why spam recall is 0.000 and spam F1 is 0.000 in the output you just ran.

💻 Rewrite predict_one to return "spam" when a message looks like spam and "ham" otherwise

After each change, re-run the script and watch the spam precision, recall, and F1 score change:

$ spam models.manual.ManualClassifier

Use the error analysis flag to see which messages your classifier gets wrong:

$ spam models.manual.ManualClassifier -a

💻 Keep refining your rules until your classifier achieves a spam F1 score above 0.70 on the test set.


[3] Feature engineering #

Each feature is a number extracted from the message. For example:

  • Does the message contain the word “free”? (0 or 1)
  • How many exclamation marks does it have? (0, 1, 2, …)
  • How long is the message in characters?

Using these features, the message "Free entry!! Call now!" would be converted into:

{
    "contains_free": 1,
    "num_exclamations": 3,
    "length": 22,
}

Walking through the code #

💻 Open models/features.py and read through it.

FeatureExtractor is a small adapter between your messages and the model. Its extract_features method returns a dictionary mapping feature names to numbers:

def extract_features(self, message):
    return {
        "contains_free": int("free" in message.lower()),
        "num_exclamations": message.count("!"),
        "length": len(message),
    }

Its transform method calls extract_features on every message, producing a list of these dictionaries—one per message:

def transform(self, X):
    return [self.extract_features(msg) for msg in X]

FeatureClassifier.fit plugs FeatureExtractor into a pipeline:

self._pipeline = Pipeline([
    ("features", FeatureExtractor()),
    ("vectorizer", DictVectorizer()),
    ("classifier", LogisticRegression(max_iter=1000)),
])

DictVectorizer converts each dictionary into a list of values in a consistent order (so the weights can be applied correctly). LogisticRegression then trains on that matrix.

💻 Run the default FeatureClassifier:

spam models.features.FeatureClassifier

You should see the spam F1 score rise significantly, and the output will now include a feature weights table:

============================================================
TOP 3 FEATURES BY WEIGHT
============================================================
  contains_free                +2.746  → spam  +++++++++++++
  num_exclamations             +0.521  → spam  ++
  length                       +0.014  → spam  

A positive weight means the feature pushes toward spam; a negative weight means it pushes toward ham. The bar gives a rough sense of magnitude.


Add Features #

💻 Add at least three features of your own to extract_features. After each addition, re-run the script and compare spam F1 before and after.

spam models.features.FeatureClassifier -a

💻 Keep adding features until you achieve a spam F1 score above 0.85


[4] Bag of Words #

So far you have designed every feature yourself. But what if you stopped guessing, and let every word be a feature instead?

This is the idea behind a bag of words model: represent each message as an unordered collection (“bag”) of its words, and use the words themselves as the evidence for classification.

The only thing that changes in this model is what extract_features returns: instead of three hand-picked measurements, it returns one entry per distinct word in the message.


Data cleaning #

Now that every distinct word is its own feature, “free”, “Free”, “FREE”, and “free!” become four different features. Cleaning the text combines these together into one feature. This reduces the number of features, and gives each feature more examples to learn from.

Open models/cleaning.py and look at LowercaseTransformer:

class LowercaseTransformer:
    def fit(self, X, y=None):
        return self

    def transform(self, X):
        return np.array([msg.lower() for msg in X])

This is another transformer, built the same way as FeatureExtractor.

The file also contains StopwordRemover (removes common words like “the”, “a”, “is”) and PunctuationRemover (replaces punctuation with spaces).


Every word is a feature #

💻 Open models/bow.py and look at FeatureExtractor’s extract_features method:

def extract_features(self, message):
    return dict(Counter(message.split()))

A message like "free entry to win a prize, text WIN to 80086" becomes a dictionary like:

{
    "free": 1,
    "entry": 1,
    "to": 2,
    "win": 2,
    "a": 1,
    "prize": 1,
    "text": 1,
    "80086": 1,
}

Now look at fit:

def fit(self, X, y):
    self._pipeline = Pipeline([
        ("lowercase", LowercaseTransformer()),
        ("punctuation", PunctuationRemover()),
        ("features", FeatureExtractor()),
        ("vectorizer", DictVectorizer()),
        ("classifier", LogisticRegression(max_iter=1000)),
    ])
    y_binary = (np.array(y) == "spam").astype(int)
    self._pipeline.fit(X, y_binary)
    return self

Interpreting the model #

💻 Run the bag-of-words classifier:

spam models.bow.BagOfWordsClassifier
👾 💬 Why the harmonic mean?

You can use the -f flag to choose how many features you see:

spam models.bow.BagOfWordsClassifier -f 25

Compare this model’s spam F1 score to your best hand-designed-features classifier. Then open bow.py and experiment with the cleaning steps at the start of self._pipeline: try adding StopwordRemover, removing PunctuationRemover, or reordering the steps. Re-run the classifier after each change and watch how both the spam F1 score and the feature weights table respond.


[5] Deliverables #

⚡✨ If you finish the lab, complete these two steps:

📋 Update Syllabus Checklist: Go to your Syllabus Content Checklist in your Google Drive and update it accordingly.

💻 Push your work to Github

  • git status
  • git add -A
  • git status
  • git commit -m “describe your code here”
  • git push
  • remote