16 KiB
16 KiB
In [ ]:
from pathlib import Path
import textwrap
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (
ConfusionMatrixDisplay,
accuracy_score,
classification_report,
confusion_matrix,
f1_score,
precision_score,
recall_score,
)
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
DATA_PATH = Path("data/SMSSpamCollection")
OUTPUT_DIR = Path("output")
RANDOM_STATE = 42
DECISION_THRESHOLD = 0.50
if not DATA_PATH.exists():
raise FileNotFoundError(
"Missing data/SMSSpamCollection. Follow data/README.md to download the official dataset."
)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
In [ ]:
raw_df = pd.read_csv(DATA_PATH, sep="\t", header=None, names=["label", "message"])
clean_df = raw_df.copy()
clean_df["label"] = clean_df["label"].astype("string").str.strip().str.lower()
clean_df["message"] = clean_df["message"].astype("string").str.strip()
clean_df = clean_df.dropna(subset=["label", "message"])
clean_df = clean_df[
clean_df["label"].isin(["ham", "spam"]) & clean_df["message"].ne("")
]
conflict_counts = clean_df.groupby("message")["label"].nunique()
conflict_texts = conflict_counts[conflict_counts.gt(1)].index
clean_df = clean_df[~clean_df["message"].isin(conflict_texts)]
clean_df = clean_df.drop_duplicates(subset=["label", "message"]).reset_index(drop=True)
clean_df["target"] = clean_df["label"].map({"ham": 0, "spam": 1}).astype(int)
X = clean_df["message"]
y = clean_df["target"]
X_train, X_test, y_train, y_test = train_test_split(
X,
y,
test_size=0.20,
random_state=RANDOM_STATE,
stratify=y,
)
print("Raw rows:", len(raw_df))
print("Clean rows:", len(clean_df))
print("Training / test:", len(X_train), len(X_test))
In [ ]:
final_pipeline = Pipeline([
("tfidf", TfidfVectorizer(
lowercase=True,
ngram_range=(1, 2),
min_df=1,
sublinear_tf=True,
)),
("model", LogisticRegression(
max_iter=2000,
random_state=RANDOM_STATE,
)),
])
final_pipeline.fit(X_train, y_train)
test_probabilities = final_pipeline.predict_proba(X_test)[:, 1]
test_predictions = (test_probabilities >= DECISION_THRESHOLD).astype(int)
print("Predictions created:", len(test_predictions))
In [ ]:
cm = confusion_matrix(y_test, test_predictions, labels=[0, 1])
tn, fp, fn, tp = cm.ravel()
metrics = {
"decision_threshold": DECISION_THRESHOLD,
"accuracy": accuracy_score(y_test, test_predictions),
"spam_precision": precision_score(y_test, test_predictions, pos_label=1, zero_division=0),
"spam_recall": recall_score(y_test, test_predictions, pos_label=1, zero_division=0),
"spam_f1": f1_score(y_test, test_predictions, pos_label=1, zero_division=0),
"true_negatives": int(tn),
"false_positives": int(fp),
"false_negatives": int(fn),
"true_positives": int(tp),
}
print(pd.Series(metrics).round(4))
print(classification_report(
y_test,
test_predictions,
target_names=["ham", "spam"],
zero_division=0,
))
In [ ]:
actions = pd.cut(
test_probabilities,
bins=[-np.inf, 0.30, 0.70, np.inf],
labels=["allow", "analyst_review", "quarantine"],
right=False,
).astype("string")
prediction_table = pd.DataFrame({
"message": X_test.to_numpy(),
"actual_label": np.where(y_test.to_numpy() == 1, "spam", "ham"),
"predicted_label": np.where(test_predictions == 1, "spam", "ham"),
"spam_probability": test_probabilities,
"action": actions,
})
prediction_table["error_type"] = np.select(
[
(prediction_table["actual_label"] == "ham") & (prediction_table["predicted_label"] == "spam"),
(prediction_table["actual_label"] == "spam") & (prediction_table["predicted_label"] == "ham"),
],
["false_positive", "false_negative"],
default="correct",
)
error_table = prediction_table[prediction_table["error_type"].ne("correct")].copy()
print("Errors by type:")
print(error_table["error_type"].value_counts())
error_table.head(10)
In [ ]:
feature_names = final_pipeline.named_steps["tfidf"].get_feature_names_out()
coefficients = pd.Series(
final_pipeline.named_steps["model"].coef_[0],
index=feature_names,
name="coefficient",
)
top_spam = coefficients.nlargest(20).reset_index()
top_spam.columns = ["term", "coefficient"]
top_spam["direction"] = "spam"
top_ham = coefficients.nsmallest(20).reset_index()
top_ham.columns = ["term", "coefficient"]
top_ham["direction"] = "ham"
top_terms = pd.concat([top_spam, top_ham], ignore_index=True)
top_terms.head(10)
In [ ]:
custom_messages = pd.Series([
"Can we move the project meeting to 3 pm?",
"Congratulations! Claim your free cash prize now",
"URGENT: verify your account at the link below",
"You are a w1nner, reply FR33 to collect your reward",
"Please call me when you arrive at the station",
], name="message")
custom_probabilities = final_pipeline.predict_proba(custom_messages)[:, 1]
custom_results = pd.DataFrame({
"message": custom_messages,
"spam_probability": custom_probabilities,
"predicted_label": np.where(custom_probabilities >= DECISION_THRESHOLD, "spam", "ham"),
"action": pd.cut(
custom_probabilities,
bins=[-np.inf, 0.30, 0.70, np.inf],
labels=["allow", "analyst_review", "quarantine"],
right=False,
).astype("string"),
})
custom_results
In [ ]:
pd.Series(metrics, name="value").to_csv(OUTPUT_DIR / "capstone_metrics.csv", header=True)
prediction_table.to_csv(OUTPUT_DIR / "capstone_predictions.csv", index=False)
top_terms.to_csv(OUTPUT_DIR / "capstone_top_terms.csv", index=False)
fig, ax = plt.subplots(figsize=(5, 4))
ConfusionMatrixDisplay(
confusion_matrix=cm,
display_labels=["ham", "spam"],
).plot(ax=ax, cmap="Blues", colorbar=False)
ax.set_title("SMS Spam Detector — Test Set")
fig.tight_layout()
fig.savefig(OUTPUT_DIR / "capstone_confusion_matrix.png", dpi=160, bbox_inches="tight")
plt.close(fig)
summary = f'''SMS Spam Detector — Analyst Summary
===================================
Source dataset: UCI SMS Spam Collection
Clean rows: {len(clean_df)}
Training rows: {len(X_train)}
Test rows: {len(X_test)}
Overall spam rate: {y.mean():.2%}
Decision threshold: {DECISION_THRESHOLD:.2f}
Test performance
----------------
Accuracy: {metrics['accuracy']:.3f}
Spam precision: {metrics['spam_precision']:.3f}
Spam recall: {metrics['spam_recall']:.3f}
Spam F1: {metrics['spam_f1']:.3f}
False positives: {fp}
False negatives: {fn}
Operational interpretation
--------------------------
Messages below 0.30 are allowed, scores from 0.30 to below 0.70 are sent
for analyst review, and scores of 0.70 or more are quarantined.
Limitation
----------
This classroom model uses SMS body text only. It does not inspect email headers,
sender domains, attachments, destination URLs, or changing attacker behavior.
'''
summary = textwrap.dedent(summary).strip() + "\n"
(OUTPUT_DIR / "capstone_summary.txt").write_text(summary, encoding="utf-8")
print(summary)
In [ ]:
expected_files = {
"capstone_metrics.csv",
"capstone_predictions.csv",
"capstone_top_terms.csv",
"capstone_confusion_matrix.png",
"capstone_summary.txt",
}
assert set(clean_df["target"].unique()) == {0, 1}
assert not clean_df.duplicated(subset=["label", "message"]).any()
assert abs(y_train.mean() - y_test.mean()) < 0.02
assert list(final_pipeline.named_steps) == ["tfidf", "model"]
assert len(test_predictions) == len(y_test)
assert cm.shape == (2, 2)
assert (tn + fp + fn + tp) == len(y_test)
assert len(prediction_table) == len(y_test)
assert set(prediction_table["action"].unique()).issubset({"allow", "analyst_review", "quarantine"})
assert len(top_terms) == 40
assert len(custom_results) == 5
assert expected_files.issubset({path.name for path in OUTPUT_DIR.iterdir()})
assert "Limitation" in summary
print("✓ Lab 6 completed")