# Changelog — Spam XAI Project > ENGT 375: Applied Machine Learning for Engineering Technology — Spring 2026, ODU > > Merged from `CHANGELOG.md` (Streamlit version) and `CHANGELOG_gradio.md` (Gradio version) > on 2026-04-07. Pre-merge entries are preserved in their original sections below. > > Reconstructed retroactively from file timestamps and code comments. This project > does not use traditional version control; dates reflect filesystem modification times. --- ## [v2.0.2] — 2026-04-16 (HuggingFace sync) ### Summary Synced latest README, requirements.txt, and project files to HuggingFace via `update-huggingface.command`. No code changes. --- ## [v2.0.1] — 2026-04-14 (HF Spaces deployment fixes) ### Summary Deployed v2 to a fresh Hugging Face Space (`VoltageVagabond/spam-xai-classifier-v2`) and fixed multiple environment / packaging issues uncovered during deployment. ### Q&A from this session **Q: Why does the v2 model load with `AttributeError: 'LogisticRegression' object has no attribute 'multi_class'`?** A: The model was serialized with scikit-learn 1.8.0 but the Space was running 1.6.1. sklearn changed the internal API for `predict_proba` between those versions. Fix: pin `scikit-learn==1.8.0` in `requirements.txt` AND bump `python_version` to `"3.11"` in the README frontmatter (1.8.0 requires Python ≥ 3.11). **Q: Why did the upload get a `403 Repository storage limit reached (Max: 1 GB)` error?** A: Spaces have a 1 GB LFS quota. v2's `models/` dir totals ~1.9 GB (includes `random_forest_raw.joblib` 168 MB, `random_forest_spam.joblib` 872 MB, `_baseline_rf/` 474 MB). The Space only needs `voting_model.joblib` (318 MB) plus the small artifacts. Fix: extended the ignore list in `update-huggingface.command` to skip the standalone RF files for v2. **Q: Why was the Space title shown as the same name as v1?** A: The README frontmatter `title:` was identical. Renamed v2's title to `Spam Email Classifier with XAI (v2)` to disambiguate. **Q: Why didn't the v2 model show up under my Hugging Face profile?** A: The `update-huggingface.command` mapping had `spam-xai-project-v2` configured as `"space"` only, not `"space model"`. Updated to upload to both `spam-xai-classifier-v2` (Space) and `spam-xai-model-v2` (model repo). ### Changes - `requirements.txt` — `scikit-learn>=1.3.0` → `scikit-learn==1.8.0` - `README.md` — `title` updated to `(v2)`, `python_version` bumped to `"3.11"` - `update-huggingface.command` — added v2 to model upload list, added size-based ignores --- ## [v2.0] — 2026-04-14 ### Summary Simplified `app.py` for a beginner audience (ENGT 375, Spring 2026). No features were removed. All tabs, explanations, and feedback logging work identically. This version lives in `spam-xai-project-v2/`. Note: The refactoring added helper functions and plain-English comments, which increased the total line count slightly compared to the original. The goal was readability, not raw line reduction. ### Changes - **Merged duplicate feedback handlers** — `handle_correct()` and `handle_wrong()` (which shared 90% of their code) were combined into one `handle_feedback()` function with an `is_correct` flag. Removes confusing duplication. - **Flattened nested SHAP function** — `predict_with_meta_only()` was defined inside `generate_shap_explanation()`. Moved it to the top level so it reads like a normal function. No logic change. - **Simplified comparison feature extraction** — `generate_comparison()` had three near-identical code blocks to get the top-3 features from LIME, SHAP, and ELI5 separately. Replaced with a single `get_top_features()` helper called three times. - **Extracted badge logic** — `generate_plain_summary()` had inline `if/elif/else` blocks for badge text selection. Extracted into a plain `get_result_badge()` helper function so the logic is in one place. - **Added section comments to orchestrator** — `classify_and_explain()` (the main function that runs when you click Classify) had no comments explaining its steps. Added short plain-English comments so a student can follow the flow. - **Added file header comment** — Four-line comment at the top of `app.py` explaining what the file does in plain English. ### Notebook Changes - **Removed code duplication** — `preprocess_text()` and `compute_metadata_features()` were redefined inside the student, instructor, and Gradio notebooks even though they already exist in `utils.py`. All notebooks now import directly from `utils.py` so they stay in sync with the Gradio app. - **Fixed hardcoded paths** — All notebooks had a Windows-only path (`C:/Users/balfo/...`) that only worked on one machine. Replaced with `Path(os.getcwd()).parent` so the notebook works on any computer — important for a turn-in assignment. - **Split 206-line feature engineering cell** — Cell 13 in the student notebook combined TF-IDF setup, phrase lists, metadata computation, and feature assembly in one wall of code. Split into three focused cells (each under 55 lines) with markdown headers explaining each step. - **Replaced all lambdas** — `sorted(..., key=lambda x: x[1])` calls across all three notebooks replaced with `pd.DataFrame` + `.sort_values()` — no lambda keyword in any executable cell. - **Replaced all list comprehensions** — All `[x for x in y]` patterns replaced with plain `for` loops and `.append()` calls. Applies to the student notebook, instructor notebook, and Gradio notebook. - **Lecture-style confusion matrix** — Upgraded to `sns.heatmap` with `YlGnBu` colormap, class labels `["Ham (not spam)", "Spam"]`, and predicted label axis on top — matching the Module_7C lecture pattern. - **Lecture-style feature importance charts** — Upgraded all three XAI method bar charts (LIME, SHAP, ELI5) to horizontal `barh` with blue/red color coding (blue = spam signal, red = ham signal) and a zero-reference line — matching the Module_7C lecture pattern. - **Expanded metrics panel** — Test set evaluation now prints each metric with a plain-English description on the same line (e.g., "Precision: 94.2% — of emails we called spam, how many really were spam"). - **Added docstrings** — Every inline function now has a one-sentence docstring explaining what it does. - **Added plain-English comments** — Comments added before regex patterns, SHAP/LIME constructor calls, `fit_transform`, `hstack`, `MinMaxScaler`, `GridSearchCV`, and `predict_proba` to explain what each does. ### Files Changed - `app.py` — refactored for readability - `notebooks/spam_classifier_xai_student.ipynb` — refactored for beginner readability - `notebooks/spam_classifier_xai.ipynb` — same fixes applied - `notebooks/spam_classifier_gradio.ipynb` — ternary, lambda, and comprehension fixes - `CHANGELOG.md` — this entry ### Files Unchanged - `utils.py`, `retrain.py`, `retrain_student.py`, `train_ensemble.py` - All model artifacts in `models/` - All data in `data/` - All notebooks in `notebooks/` ## v1.5 — 2026-04-07 ### Full retrain + project cleanup - **Full ensemble retrain** — ran `retrain-full.command` with no feedback corrections; trained VotingClassifier (RF + LR + SVM) on 190,543 emails; achieved 97% accuracy, 0.97 macro F1, optimal threshold 0.5176 - **Deleted project cruft** — removed macOS resource forks (`._*`), `__pycache__/`, `.DS_Store`, stale `.server.log` - **Deleted legacy app files** — removed `app_legacy.py` and `app_student_legacy.py` (old Streamlit-era versions, superseded by `app.py`) - **Deleted legacy notebooks** — removed `notebooks/_legacy/` and `notebooks/.ipynb_checkpoints/` - **Deleted legacy docs** — removed `docs/_legacy_plans/` (pre-merge design notes from March) - **Deleted old generic model backup** — removed `models_backup/`; kept `models_backup_pre_fast/` and `models_backup_pre_full/` as rollback snapshots for the two most recent retrains --- ## v1.4 — 2026-04-07 ### Bug fixes and UI improvements - **Fixed PyArrow indexing crash in `retrain.py`** — HuggingFace datasets return PyArrow-backed columns; `.values` returned a PyArrow array that `train_test_split` couldn't index. Changed to `.to_numpy()` at all three call sites (lines 159, 160, 183, 184) - **Fixed fast-mode model type mismatch in `app.py`** — fast retrain saves a bare `RandomForestClassifier` but `app.py` was unconditionally calling `.named_estimators_['rf']` (a `VotingClassifier` attribute). Added `hasattr` check so both fast (RF) and full (VotingClassifier) models load correctly - **Fixed ELI5 rendering in Gradio** — `eli5.format_as_html()` wraps methodology boilerplate in a `
` block that overflows the container. Now strips `
` blocks and wraps output in a `overflow-wrap: break-word` div so only the contribution table shows
- **Widened app layout** — increased `.gradio-container` `max-width` from 1180px to 1600px to reduce cramped appearance on wide monitors

---

## v1.3 — 2026-04-07

### Parallelized preprocessing; updated time estimates

- **Parallelized `preprocess_text` in `retrain.py`** — replaced single-threaded `.apply()` with `joblib.Parallel(n_jobs=-1, prefer='threads')` to use all CPU cores during text cleaning/stemming
- **Updated time estimates** to `~5-10 minutes` (fast mode) in `retrain.py` header, runtime print statement, and `retrain-fast.command` banner; previous estimate of `~2-5 minutes` was inaccurate for the 190K-email corpus

---

## v1.2 — 2026-04-07

### Training data overhaul — replaced Kaggle with research-grade corpora

- **Dropped Kaggle `spam_Emails_data.csv`** — dataset was noisy/synthetic-heavy and capped at 100K rows; replaced with three research-grade sources
- **Added Enron corpus** (~33K real corporate emails from `data/raw/enron/enron_spam_data.csv`) — file already existed but was not being loaded by the retrain scripts
- **Added puyang2025/seven-phishing-email-datasets** (HuggingFace) — 138K emails from 7 research corpora (TREC-05, TREC-06, TREC-07, CEAS-08, SpamAssassin, Ling-Spam; Enron sub-corpus excluded to avoid duplication). Saved to `data/raw/puyang2025/seven_phishing_emails.parquet`
- **Added zefang-liu/phishing-email-dataset** (HuggingFace) — 18,634 phishing-labeled emails. Saved to `data/raw/zefang/phishing_emails.parquet`
- **New total training data: ~190,543 emails** (was ~100K Kaggle sample)
- **Updated `retrain.py` and `retrain_student.py`** to load all three new sources; removed dead `email-dataset-main` directory-traversal code
- **Updated `build_datasets.py` and `build_liquid_datasets.py`** to include puyang2025 and zefang as additional sources for MLX/Liquid LLM fine-tuning data
- **Updated `HOW_TO_RUN.html`** to reflect new data sources and file paths

---

## v1.1 — 2026-04-07

### Merged spam-classifier-gradio into spam-xai-project

- **File merge:** Moved all of `spam-classifier-gradio/` into `spam-xai-project/`. The two projects shared the same data, preprocessing, and feature engineering — keeping them separate was creating drift and duplicate code.
- The Gradio app (`app.py`), VotingClassifier ensemble training (`train_ensemble.py`), retrain script with feedback support (`retrain.py`), and the Gradio notebook (`notebooks/spam_classifier_gradio.ipynb`) all live in the merged project now.
- **Preserved as legacy** (renamed but kept):
  - `app_legacy.py`, `app_student_legacy.py` — older Streamlit/Gradio app versions
  - `MODEL_README_legacy.md`, `README_legacy.md` — older HF cards
  - `retrain_legacy.py` — older RF-only retrain
  - `notebooks/_legacy/spam_classifier_xai.ipynb`, `_legacy/spam_classifier_xai_executed.ipynb`
  - `models/_baseline_rf/` — older RF-only model artifacts
- **Reference docs added:** Built out `docs/references/` with 20 PDFs (LIME, SHAP, TreeSHAP, Anchors, Pedregosa sklearn, Kuzlu et al. solar XAI, 5 spam-detection survey papers) and 11 HTML guides (LIME/SHAP/ELI5 docs, sklearn user guide pages, Gradio quickstart, HF Spaces docs, Molnar Interpretable ML book). All linked from `docs/references/how-to.html`. Shared papers (Attention, LoRA, QLoRA, PEFT survey) live in the top-level `references/` folder used by all three sibling projects.
- **Notebook style cleanup:** The student notebook (`spam_classifier_xai_student.ipynb`) and the Gradio notebook (`spam_classifier_gradio.ipynb`) were rewritten in places to remove "senior engineer" code patterns: replaced lambdas with named helpers, broke chained list-comp+sort+slice into explicit loops, and softened comments to read more like an undergraduate beginner wrote them. Functionality unchanged. Backups saved as `*.ipynb.bak`.
- **HF deployment:** Updated `update-huggingface.command` and `upload_to_hf.py` to push the merged project to TWO new HF repos:
  - `VoltageVagabond/spam-xai-classifier` (Space, Gradio SDK)
  - `VoltageVagabond/spam-xai-model` (Model)
  - The old `spam-classifier-gradio` Space and `spam-classifier-gradio-model` Model are archived (not deleted).

### Cleanup pass — same day

- Merged two `CHANGELOG.md` files into this one
- Merged `MODEL_README.md` + `MODEL_README_legacy.md` into one model card
- Merged `README.md` body content (kept HF Space YAML frontmatter intact)
- Consolidated retrain scripts: one canonical `retrain.py` with `--mode {fast,full}`, plus `retrain-fast.command` and `retrain-full.command` matching the Liquid project's pattern
- Wired all three launcher commands to existing target files (`launch-app.command`, `launch-gradio.command`, `launch-notebook.command`)
- Deleted three Windows `.bat` files (`retrain.bat`, `run_app.bat`, `run_app_student.bat`) — macOS-only project now

---

## Pre-merge — Gradio version (originally `CHANGELOG_gradio.md`)

### v0.2.1 — 2026-03-28
**Retrain with Auto-Backup**
- `retrain.command` now automatically backs up `models/` to `models_backup/` before retraining

### v0.2.0 — 2026-03-28
**HuggingFace Upload + Retrain Command**
- Uploaded project to HuggingFace Space: `VoltageVagabond/spam-classifier-gradio` (Gradio SDK)
- Created `README.md` with HF Space YAML frontmatter
- Uploaded model `.joblib` files to HF Space for live demo
- Added `retrain.command` for the sklearn ensemble
- Uploaded all training data to HF dataset: `VoltageVagabond/spam-email-dataset`

### v0.1.3 — 2026-03-23
**UI Fix: Scroll-to-Top Bug**
- Fixed page jumping to top when pasting email or clicking Classify
- Added `autoscroll=False`, `cache_examples=False`, `scroll_to_output=False`

### v0.1.2 — 2026-03-23
**Bug Fixes + Real-World Testing**
- Fixed SHAP crash: `shap_values` returned multi-dimensional arrays from KernelExplainer; now `.flatten()` to 1D before sorting
- Added try/except around SHAP so Result and LIME tabs still show if SHAP fails
- Pinned `gradio==4.19.2` (Gradio 4.44.1 has a Python 3.9 bug)
- Added `launch-notebook.command` for Jupyter notebook

**Known Limitation: Legitimate Marketing Emails Misclassified**
- Tested with a real Lenovo Rewards referral email — classified as **SPAM at 78% confidence** (false positive)
- Root cause: dollar signs, "earn", "rewards", "purchase" vocabulary overlaps heavily with spam training patterns
- LIME and SHAP correctly showed which features drove the misclassification — demonstrating the value of XAI for understanding model failures
- This is a known weakness of bag-of-words/TF-IDF classifiers

### v0.1.1 — 2026-03-23
**Bug Fix: SHAP numpy indexing**
- Fixed `TypeError: only integer scalar arrays can be converted to a scalar index` in SHAP plot generation
- `np.argsort` returns numpy int64 indices; Python list indexing needs plain `int`

### v0.1.0 — 2026-03-23
**Initial Build**
- Created fresh Gradio project (replacing old Streamlit version)
- Ported preprocessing and 24 metadata features from old `utils_student.py`
- Loaded Kaggle spam dataset (~190K emails, capped at 100K stratified sample)
- Trained and compared 3 models:
  - Random Forest: 97.75% accuracy, F1=0.976
  - Logistic Regression: 96.57% accuracy, F1=0.964
  - SVM (LinearSVC + calibration): 96.89% accuracy, F1=0.967
- Combined into VotingClassifier (soft voting): 97.40% accuracy, F1=0.973
- Optimal threshold: 0.3714 (targeting 99% ham precision)
- Built Gradio interface with text + .txt file upload, Result tab, LIME tab, SHAP tab
- 4 built-in example emails

---

## Pre-merge — Streamlit version (originally `CHANGELOG.md`)

### v0.3.1 — 2026-03-28
**Retrain with Auto-Backup** (same fix as gradio v0.2.1)

### v0.3 — 2026-03-28
**HuggingFace Upload + Command Launchers**
- Uploaded to HuggingFace Space: `VoltageVagabond/spam-xai-project` (Docker + Streamlit)
- Created Dockerfile for HF Space deployment
- Added `retrain.command` and `launch-app.command`

### v1.0 — 2026-03-23
**Documentation**
- Added `docs/` directory with project documentation
- Created the original `CHANGELOG.md`

### v0.9 — 2026-03-09
**Interactive feature explorer**
- Generated `spam-feature-explorer.html` — standalone HTML/JS page for interactively exploring the 24 metadata features

### v0.8 — 2026-03-08
**Student version and LLM feature extraction**
- Created `app_student.py` — full rewrite of `app.py` with extensive inline comments
- Imports all shared logic from `utils_student.py`
- Added `streamlit_js_eval` for persisting classification results in browser localStorage
- Added user feedback system: `save_feedback()` writes corrections to `data/feedback/feedback_log.csv`
- Created `retrain_student.py` — student version of training script with explanatory comments, optional LLM feature extraction via Ollama, SHA-256 hash-based caching of LLM features
- Defined 6 LLM intent/tone features: `intent_promotional`, `intent_transactional`, `intent_personal`, `intent_phishing`, `tone_urgency`, `tone_formality`
- `SKIP_LLM_TRAINING` flag defaults to `True` for fast retraining
- Created `test_accuracy.py` for quick model smoke-testing
- Created student notebook (`spam_classifier_xai_student.ipynb`)

### v0.7 — 2026-03-06
**New training datasets and MinMaxScaler**
- Switched primary training data from SpamAssassin + Enron to Kaggle 190K + GitHub email-dataset
- Added `MinMaxScaler` for metadata features
- Added user feedback integration: corrections weighted 5x and injected into training
- Reduced GridSearchCV grid from 36 to 4 combinations and CV from 5 to 3 folds

### v0.6 — 2026-03-06
**Expanded feature engineering (11 to 24 metadata features)**
- Created `utils_student.py` — shared utilities module
- Added 13 new metadata features (features 12-24): `has_specific_date`, `has_specific_time`, `date_reference_count`, `has_unsubscribe`, `has_physical_address`, `has_proper_greeting`, `has_contact_info`, `registration_language_score`, `cta_to_info_ratio`, `shortener_url_ratio`, `legitimate_platform_count`, `gov_edu_url_count`, `question_mark_count`
- Expanded `generate_newsletters_student.py` from 50 to 600 target newsletters

### v0.5 — 2026-03-05
**OCR support, dark mode, and header feature extraction**
- Optional `pytesseract` + Pillow for classifying email screenshots
- `extract_header_features()` parses From/To/Subject headers to detect government domains, mailing-list patterns, etc.
- Full dark mode with theme-aware matplotlib + ELI5 HTML overlay
- Created `run_app_student.bat`, `_restyle_notebook.py`

### v0.4 — 2026-03-04
**Newsletter augmentation and generation**
- Created `generate_newsletters.py` producing 50 synthetic government/institutional newsletters via Ollama/Qwen
- **Change 9** in `retrain.py`: sentence-shuffled newsletter variants (5 per ham email) to improve classification of legitimate promotional-style emails
- Created initial Jupyter notebook (`spam_classifier_xai.ipynb`)

### v0.3 — 2026-03-04
**Ollama/Qwen LLM integration**
- **Change 3:** `get_llm_second_opinion()` calling Ollama with Qwen 3.5 to independently classify emails
- RF and LLM confidence scores blended with context-aware weighting
- Added AI Explanation tab using Qwen 3.5 to produce plain-English explanations from top-5 LIME/SHAP/ELI5 features
- **Change 1:** Default threshold raised to 0.60

### v0.2 — 2026-03-04
**Context-aware phrase lists and domain whitelist**
- Added `SPAM_CONTEXT_PHRASES` (14 phrases: "act now", "limited time", etc.) and `HAM_CONTEXT_PHRASES` (12 phrases: "click to unsubscribe", "official notice", etc.)
- Added `check_domain_trust()` extracting sender domain from `From:` / `Return-Path:` headers

### v0.1 — 2026-03-04
**Initial Streamlit app and training pipeline**
- Created `requirements.txt` with scikit-learn, LIME, SHAP, ELI5, Streamlit, NLTK, matplotlib, scipy
- Built `retrain.py` loading SpamAssassin + Enron-Spam, 3000 TF-IDF features + 11 metadata features
- GridSearchCV over a Random Forest, `class_weight='balanced'`, isotonic probability calibration, optimal threshold targeting 99% ham precision
- Created `app.py` — Streamlit UI with text-area input, example emails, confidence gauge, four explanation tabs (LIME, SHAP, ELI5, Comparison)
- Added `run_app.bat` and `HOW_TO_RUN.html`

---

## Training Methodology Reference (for paper)

### Complete Training Pipeline (current — post-merge)

1. **Data sources:**
   - Kaggle spam dataset (`data/spam_Emails_data.csv`, ~193K emails, stratified-sampled to 100K)
   - GitHub email-dataset (folder 1 = ham, folder 2 = spam)
   - Optional: SpamAssassin + Enron + synthetic newsletters (legacy data sources)

2. **Preprocessing:** HTML removal, URL removal, email address removal, non-alphabetic removal, lowercasing, NLTK stopword removal, Porter stemming

3. **Feature engineering:**
   - **TF-IDF:** 3,000 features, ngram_range=(1,3), min_df=2, max_df=0.90, sublinear_tf=True
   - **24 hand-crafted metadata features:** exclamation_density, dollar_sign_count, caps_word_ratio, spam_phrase_count, ham_phrase_count, net_spam_context, url_count, html_tag_count, email_length, avg_sentence_length, capitalization_ratio, has_specific_date, has_specific_time, date_reference_count, has_unsubscribe, has_physical_address, has_proper_greeting, has_contact_info, registration_language_score, cta_to_info_ratio, shortener_url_ratio, legitimate_platform_count, gov_edu_url_count, question_mark_count
   - **MinMaxScaler** on metadata features (so they match the 0-1 TF-IDF scale)
   - Combined via `scipy.sparse.hstack` → 3,024 total features

4. **Train/test split:** 70/30, stratified, random_state=42

5. **Models:**
   - Random Forest: n_estimators=200, class_weight='balanced' → 97.75% accuracy, F1=0.976
   - Logistic Regression: max_iter=1000, class_weight='balanced' → 96.57% accuracy, F1=0.964
   - SVM (LinearSVC + CalibratedClassifierCV): class_weight='balanced' → 96.89% accuracy, F1=0.967
   - **VotingClassifier (soft voting, all 3): 97.40% accuracy, F1=0.973** ← deployed model

6. **Threshold optimization:** Precision-recall curve targeting 99% ham precision → optimal threshold 0.3714

7. **Explainability:**
   - LIME: `LimeTabularExplainer`, 200-row training sample, 10 features per explanation
   - SHAP: `KernelExplainer` on 24 metadata features only (TreeExplainer too slow on 3,000+ features), 50-row background
   - ELI5: `PermutationImportance` on the full 3,024-feature space

8. **Framework:** scikit-learn 1.6.1
9. **Hardware:** MacBook Pro M4 Pro, 24 GB unified RAM

### Key Hyperparameters

| Parameter | Value | Rationale |
|-----------|-------|-----------|
| TF-IDF max_features | 3,000 | Balance between vocabulary coverage and dimensionality |
| TF-IDF ngram_range | (1, 3) | Capture phrases like "act now", "limited time offer" |
| TF-IDF min_df | 2 | Remove extremely rare words |
| TF-IDF max_df | 0.90 | Remove words appearing in 90%+ of emails |
| TF-IDF sublinear_tf | True | Logarithmic term frequency scaling |
| RF n_estimators | 200 | Sufficient trees for stable predictions |
| class_weight | 'balanced' | Handle spam/ham class imbalance |
| test_size | 0.3 | 70/30 split |
| random_state | 42 | Reproducibility |

### Evolution of the Project

| Version | Features | Model | Data | UI |
|---------|----------|-------|------|-----|
| v0.1 (Streamlit) | 11 metadata + 3000 TF-IDF | RF + GridSearchCV + Calibration | SpamAssassin + Enron | Streamlit |
| v0.6 | 24 metadata + 3000 TF-IDF | Same | + Kaggle + GitHub | Streamlit |
| v0.8 | 24 metadata + 3000 TF-IDF + optional LLM | Same | + user feedback (5x weighted) | Streamlit + student version |
| v0.1.0 (Gradio) | 24 metadata + 3000 TF-IDF | RF + LR + SVM VotingClassifier | Kaggle 100K | Gradio (rewrite from Streamlit) |
| v1.1 (merged) | 24 metadata + 3000 TF-IDF | VotingClassifier (canonical) | Kaggle + GitHub + optional Spam Assassin | Gradio (canonical) |

### Known Limitations

- Legitimate marketing emails (e.g., Lenovo Rewards) misclassified as spam at high confidence due to vocabulary overlap with spam patterns (dollar signs, "earn", "rewards")
- TF-IDF is bag-of-words — cannot understand email intent or context
- SHAP limited to metadata features only (KernelExplainer too slow on 3,000+ features)
- The voting model is large (152 MB) — pushing to a free HF Space requires explicit inclusion in the upload script

### Citations

- Pedregosa, F., et al. (2011). "Scikit-learn: Machine Learning in Python." JMLR 12, 2825-2830
- Ribeiro, M.T., et al. (2016). "'Why Should I Trust You?': Explaining the Predictions of Any Classifier." KDD 2016 (LIME)
- Lundberg, S.M. & Lee, S.I. (2017). "A Unified Approach to Interpreting Model Predictions." NeurIPS 2017 (SHAP)
- Lundberg, S.M., et al. (2020). "From local explanations to global understanding with explainable AI for trees." Nature MI 2(1) (TreeSHAP)
- Kuzlu, M., et al. (2020). "Gaining Insight Into Solar PV Power Generation Forecasting Utilizing Explainable AI Tools." IEEE Access 8 (XAI feature reduction methodology)
- Breiman, L. (2001). "Random Forests." Machine Learning 45(1)