An end-to-end spam message classification web application using TF-IDF, Logistic Regression, a Django REST API, and a vanilla JavaScript frontend. The trained model classifies short messages (SMS-style text) as spam or ham.
- Overview
- Features
- Screenshots
- Tech Stack
- Dataset
- How It Works: The ML Pipeline
- Model Performance
- Project Structure
- Local Setup
- API Reference
- Deployment Architecture
- Known Limitations
- Roadmap
- Documentation Note
- Author
- License
This project packages a classic text-classification problem — deciding whether a short message is spam or legitimate — as a full-stack application:
- ML core: a
scikit-learnLogistic Regression classifier trained on 5,572 SMS messages from the SMS Spam Collection dataset using TF-IDF vectorization. The trained model and vectorizer are persisted as.joblibartifacts. - Backend: a Django REST Framework API that loads both artifacts once at app startup and serves predictions as JSON.
- Frontend: a single-page vanilla HTML/CSS/JS UI that posts a message to the API and renders the spam/ham verdict.
The prediction flow does not use the Django database; it runs entirely in memory on the pre-loaded model and vectorizer.
- Logistic Regression + TF-IDF — an interpretable pipeline (
sklearn.linear_model.LogisticRegression, default hyperparameters) that runs without a GPU. - Eager model loading — the model and vectorizer are loaded once when the Django app starts (
api/apps.py), so every request hits a warm model. - CORS-restricted JSON API — CORS is configured only for the GitHub Pages origin
https://sandeepkumarkuanar.github.io(seeCORS_ALLOWED_ORIGINSinspam_project/settings.py), so browsers block calls from any other origin. - Dependency-free frontend — a single-page UI in plain HTML, CSS, and JavaScript (no build step, no framework).
- Persisted model artifacts —
model/spam_detector_model.joblibandmodel/tfidf_vectorizer.jobliblet the model be redeployed without retraining.
The live web UI — type a message, get a verdict.
| Layer | Technology |
|---|---|
| Language | Python 3.12 |
| ML | scikit-learn (LogisticRegression, TfidfVectorizer), joblib for persistence |
| Data | pandas (train_model.py only) |
| Backend | Django 5.2, Django REST Framework, django-cors-headers |
| Frontend | Vanilla HTML5, CSS3, JavaScript (no framework) |
| Deployment | Render Web Service (Gunicorn as the WSGI server) · GitHub Pages (static frontend) |
| Versioning | Git, with model artifacts tracked as binaries |
Dependencies are declared unpinned in requirements.txt.
The dataset is the SMS Spam Collection (model/dataset/spam.csv), a public dataset of SMS messages widely used for spam classification research. It is not an email dataset.
- 5,572 messages × 2 columns (
Category,Message) - Binary labels:
ham(legitimate) andspam - Class distribution is imbalanced: 4,825 ham (~86.6%) vs. 747 spam (~13.4%), which matters when interpreting the accuracy figure below.
Training logic lives in train_model.py:
- Load & clean —
model/dataset/spam.csvis read; missing values are replaced with empty strings. - Encode labels —
hamis mapped to0andspamto1. - Split — 80/20 train/test split (
test_size=0.2,random_state=3). - Vectorize — a
TfidfVectorizer(min_df=1, stop_words="english", lowercase=True)is fit on the training messages. - Train — a
LogisticRegression(default hyperparameters) is fit on the TF-IDF features. - Evaluate — test accuracy is printed to the console.
- Persist — the trained model and vectorizer are saved with
joblibtomodel/.
The Django API mirrors this at serving time. api/apps.py loads both .joblib files into memory when the app starts, and api/views.py transforms each incoming message with the same vectorizer before calling model.predict(), guaranteeing train/serve consistency.
Request/response flow:
- A user types a message in
index.html. scripts/main.jsPOSTs{"message": "..."}to/api/predict/viafetch.PredictView.postreads themessagefield; if it is empty or missing, it returns400 {"error": "Message not provided"}.- Otherwise the message is vectorized and passed to the model, returning
200 {"prediction": "spam"}or200 {"prediction": "ham"}. - The frontend renders the verdict in a modal.
| Dataset | Accuracy |
|---|---|
| Test | ≈ 0.967 |
This is a single hold-out accuracy of ≈0.967 (≈96.7%) on the fixed 20% test split (1,115 messages, random_state=3). It is the only metric the current implementation computes.
Because the dataset is ~86.6% ham, a "predict everything as ham" baseline already scores ~0.87. This single accuracy figure should not be read as proof of production-quality spam detection; see Known Limitations.
spam-email-classifier/
├── api/ # Django app exposing the prediction API
│ ├── apps.py # Loads model + vectorizer at app startup
│ ├── urls.py # /api/predict/ route
│ ├── views.py # PredictView API endpoint
│ ├── tests.py # Empty stub (no tests)
│ └── migrations/
├── spam_project/ # Django project config (settings, urls, wsgi/asgi)
│ ├── settings.py
│ ├── urls.py
│ ├── wsgi.py
│ └── asgi.py
├── model/ # ML artifacts
│ ├── dataset/
│ │ └── spam.csv # SMS Spam Collection training data
│ ├── main.py # Standalone experimentation script (not used by the app)
│ ├── spam_detector_model.joblib # Trained LogisticRegression classifier
│ └── tfidf_vectorizer.joblib # Trained TF-IDF vectorizer
├── screenshots/
│ └── landing.png # Web UI screenshot
├── scripts/
│ └── main.js # Frontend JS (API client + UI handling)
├── styles/
│ └── main.css # Frontend styles
├── index.html # Web frontend (single page)
├── train_model.py # ML pipeline: train, evaluate, save
├── manage.py # Django management entrypoint
├── requirements.txt # Python dependencies (unpinned)
├── db.sqlite3 # Django SQLite DB (not used by the prediction flow)
├── README.md
├── LICENSE
└── .gitignore
- Python 3.12 or newer
pip
-
Clone the repository (from the repo root, not this directory name, after cloning):
git clone git@github.com:SandeepKumarKuanar/Spam-email-detector.git cd Spam-email-detector -
Create and activate a virtual environment
python -m venv venv source venv/bin/activate # Windows: venv\Scripts\activate
-
Install dependencies
pip install -r requirements.txt
-
(Optional) Retrain the model
python train_model.py
This re-splits the dataset, retrains the classifier, prints the hold-out test accuracy, and overwrites
model/spam_detector_model.joblibandmodel/tfidf_vectorizer.joblib. Run it from the repository root because it uses relative paths. Note: the script currently fails on newer pandas releases (see Known Limitations). -
Run the Django API
python manage.py runserver
Before this works locally, add
127.0.0.1andlocalhosttoALLOWED_HOSTSinspam_project/settings.py(it currently only lists the Render hostname). The API is then available athttp://127.0.0.1:8000/api/predict/. -
Open the frontend
Open
index.htmlin a browser. It is a static page that calls the API via CORS, but the API only allows the GitHub Pages origin, and the API URL is hardcoded to the production Render endpoint inscripts/main.js:45. To run the frontend end-to-end locally you must also add your local origin toCORS_ALLOWED_ORIGINSand update the hardcoded URL.
Base URL: https://spam-email-detector-1-843g.onrender.com/api/ (or http://127.0.0.1:8000/api/ locally)
Classifies a short message as spam or ham.
Request:
{
"message": "Congratulations! You won a free iPhone. Click here to claim."
}Example:
curl -X POST https://spam-email-detector-1-843g.onrender.com/api/predict/ \
-H "Content-Type: application/json" \
-d '{"message": "Winner! Click here to claim your free prize now."}'Responses:
| Code | Meaning |
|---|---|
200 |
Prediction successful: {"prediction": "spam"} or {"prediction": "ham"} |
400 |
No message provided: {"error": "Message not provided"} |
This is the only endpoint the API implements.
The application is split into two independent hosts:
- API (Render Web Service): the Django project is served by Gunicorn with
gunicorn spam_project.wsgi:application. The model, vectorizer, and dataset ship inside the API repository; no database or external service is required at runtime. - Frontend (GitHub Pages): the static
index.html,scripts/main.js, andstyles/main.cssare served from GitHub Pages at the live demo URL.
The static frontend calls the API over CORS. CORS_ALLOWED_ORIGINS in spam_project/settings.py contains only https://sandeepkumarkuanar.github.io, so browsers will block API calls from any other origin. ALLOWED_HOSTS contains only spam-email-detector-1-843g.onrender.com.
From the Render dashboard:
- Build command
pip install -r requirements.txt
- Start command
gunicorn spam_project.wsgi:application
ALLOWED_HOSTS— add the Render hostname tosettings.pyif the domain changes, and add127.0.0.1/localhostfor local development.- Environment variables —
SECRET_KEYandDEBUGare currently set directly insettings.py(DEBUG = True). For a hardened deployment, move both to environment variables (DEBUG=False) and set them from the Render dashboard. See Known Limitations. - CORS — if the UI is deployed on another origin, add it to
CORS_ALLOWED_ORIGINS; otherwise browsers will block the API calls.
- Imbalanced dataset, plain accuracy metric. The dataset is ~86.6% ham, so a "predict everything as ham" baseline already scores ~0.87. The reported ≈0.967 test accuracy is a single hold-out number (
random_state=3), with no cross-validation and no precision/recall or confusion-matrix breakdown. - Retraining depends on an old pandas behavior.
train_model.pyassigns integer labels (0/1) directly into the string-dtypeCategorycolumn. This works on the pandas 1.x line but raises aTypeErroron current pandas releases, so the documented retrain step cannot run on a fresh install today without a compatibility fix. - Debug settings are enabled.
DEBUG = Trueand a hardcodedSECRET_KEYinsettings.pyare convenient locally but are not production-safe.requirements.txtis also unpinned. - Restrictive host/origin allowlists.
ALLOWED_HOSTSandCORS_ALLOWED_ORIGINSare hardcoded to the current Render hostname and GitHub Pages origin; running locally or serving the UI elsewhere requires editingsettings.py. - Manual retrain flow. Re-running
train_model.pyoverwrites the model files, but the deployed service picks them up only after a redeploy — there is no automated retrain/redeploy pipeline. - Hardcoded frontend API URL.
scripts/main.js:45 points at the production Render URL; local testing requires editing it by hand. The frontend copy also refers to "email", while the trained model operates on SMS-style messages. - No automated tests.
api/tests.pyis an empty stub — the/api/predict/endpoint has no automated coverage. - Generated artifacts are still tracked. Despite the entries in
.gitignore,db.sqlite3and both.joblibfiles were committed before the ignore rules were added and remain under version control.
- Add automated tests + CI — cover
/api/predict/(spam/ham predictions, missing message → 400) with a GitHub Actions workflow. - Harden configuration — move
SECRET_KEY/DEBUG/ALLOWED_HOSTS/ CORS to environment variables and pin dependency versions. - Better evaluation — add precision/recall and a confusion matrix, try class balancing (e.g.,
class_weight), and compare against alternatives (Naive Bayes, SVM) to close the gap on the majority-class baseline. - Fix retraining — make
train_model.pycompatible with current pandas and document exact dependency versions. - Configure the frontend without editing source — make the API base URL configurable, and align the UI copy with the SMS dataset.
- Git hygiene — untrack
db.sqlite3and the.joblibartifacts now that.gitignorecovers them.
This README was updated with assistance from OpenCode. The application implementation and code were developed by me; OpenCode was used here only to improve and maintain the project documentation.
Sandeep Kumar Kuanar
- Blog / Contact: sandeepkumarkuanar.pythonanywhere.com/contact
- GitHub: SandeepKumarKuanar
- X: @kuanar_sandeep
- Email: kuanarsandeepkumar@gmail.com
MIT © 2026 Sandeep Kumar Kuanar
