Files
labs/Notebooks-CSAI/Lab4_Email_Phishing_ML.ipynb
T

1362 lines
96 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Module 4 — Máy học chống tin nhắn lừa đảo / rác, trên tin nhắn SMS THẬT\n",
"\n",
"> **Cách làm:** bấm vào một ô code rồi nhấn **Shift + Enter** để chạy ô đó và sang ô kế tiếp. Chạy **lần lượt từ trên xuống**.\n",
"> Kernel phải là **Python (lớp CSAI)** (góc trên bên phải). Ô có dòng **✏️ SỬA** là chỗ mời bạn đổi số / chữ rồi chạy lại.\n",
"> Sửa hỏng? Menu **Kernel → Restart Kernel and Run All Cells**.\n",
"\n",
"### 🎯 Mục tiêu của bài\n",
"\n",
"Học xong bài này, bạn:\n",
"1. Chỉ ra được **vì sao lọc bằng từ khóa thất bại** (chặn nhầm và bỏ lọt), đo trên tin nhắn thật.\n",
"2. Hiểu cách **biến chữ thành số** (TF-IDF) và vì sao phải **chia dữ liệu học / thi** trước khi chấm điểm.\n",
"3. Huấn luyện và so sánh **5 cách phân loại**: Perceptron, SVM, Logistic Regression, Naive Bayes, Hội đồng (Voting).\n",
"4. Đọc được **ma trận nhầm lẫn, precision, recall, F1** và chọn **ngưỡng** theo cái giá của chặn nhầm / bỏ lọt.\n",
"5. Thấy kẻ tấn công **lừa AI** thế nào (chèn \"từ an toàn\") và cách **phòng thủ**.\n",
"\n",
"Notebook giải thích từng khái niệm ngay tại chỗ, theo thứ tự bài giảng Module 4. Mọi con số trong phần \"Kết quả\" là số **đo thật** khi chạy notebook này.\n",
"\n",
"**Dữ liệu:** *SMS Spam Collection* — bộ **tin nhắn SMS thật** (tiếng Anh; bao nhiêu tin, bao nhiêu tin rác thì ô 0.1 sẽ tự đếm) do người dùng gửi lên mạng hoặc tình nguyện chia sẻ cho nghiên cứu, mỗi tin đã được gắn nhãn `ham` (tin thật) hoặc `spam` (tin rác / lừa đảo). Tác giả: T. A. Almeida & J. M. Gómez Hidalgo (2011), cho phép dùng miễn phí, ghi nguồn (file `SMSSpamCollection_readme.txt` đi kèm). Tin nhắn rác và email lừa đảo cùng một bản chất: **văn bản dụ người nhận bấm, gọi, trả tiền hoặc khai thông tin** — mọi kỹ thuật trong bài dùng y nguyên cho email.\n",
"\n",
"### 🗺️ Bài này: bài toán gì, mô hình nào, vì sao?\n",
"\n",
"- **Bài toán:** một tin nhắn đến → **chặn** (rác / lừa đảo) hay **cho qua** (tin thường)? Đây là bài **phân loại 2 lớp**.\n",
"- **Dữ liệu:** **văn bản**, **đã có nhãn** sẵn (`ham` / `spam`, do tác giả bộ dữ liệu gắn theo nguồn lấy tin — xem đầu Phần 0) nhưng **còn bẩn**: có bản sao (gửi hàng loạt), viết tắt, ký hiệu tiền tệ.\n",
"- **Vì vậy chọn:** đã có nhãn → **học có giám sát**; là văn bản → phải **biến chữ thành số** (TF-IDF) rồi dùng các mô hình hợp với văn bản: Perceptron, SVM, Logistic Regression, Naive Bayes, và **hội đồng** của chúng (bảng ② trong mục *Trước khi bắt đầu* ngay dưới đây).\n",
"- **Thước đo:** chặn nhầm tin thật (FP) và bỏ lọt tin rác (FN) đều có giá → không chỉ nhìn accuracy.\n",
"\n",
"**Pipeline — cách làm mọi bài toán AI trong khoá, nhớ thuộc lòng 7 bước.** Mỗi phần ghi **📍 Bước** ở đầu:\n",
"\n",
"| Bước | Câu hỏi phải trả lời | Phần |\n",
"|---|---|---|\n",
"| 1. **Thu thập dữ liệu** | dữ liệu gì, lấy ở đâu, thu thập thế nào, **có nhãn hay không nhãn**? | Phần 0 (5 câu hỏi), 0.1 |\n",
"| 2. **Làm sạch dữ liệu** | có gì bẩn: bản sao, viết tắt, ký hiệu lạ? | 0.2 |\n",
"| 3. **Chọn mô hình phù hợp với bài toán** | bài toán gì, có nhãn không, cách cũ làm được đến đâu? | 0.3, Phần 1 (mốc: luật từ khóa) |\n",
"| 4. **Coding** | biến chữ thành số, chia học / thi, thử **5 mô hình ứng viên** trên cùng dữ liệu | Phần 2 – 8 |\n",
"| 5. **Lưu ý khi coding** | lỗi nào làm điểm đẹp **ảo** mà code không báo lỗi? | 0.2 (bỏ bản sao **trước** khi chia), 2.2 (học từ điển **chỉ** từ phần học) |\n",
"| 6. **Đánh giá các mô hình** | so mọi ứng viên trên cùng đề thi, chọn mô hình và **ngưỡng**, thử kẻ tấn công lừa mô hình | Phần 9, 10 |\n",
"| 7. **Quy tắc bàn tay phải** ✋ | gói lại thành điều mang theo khi đi làm | Phần 11 |\n",
"\n",
"Các mô hình ở Phần 3 – 8 **không phải bước bắt buộc nối tiếp nhau**: chúng là **các ứng viên** cho cùng bước 4, ta thử từng cái rồi chọn ở bước 6.\n",
"\n",
"📌 **Tra cứu nhanh:** *Chọn mô hình nào? Khi nào dùng gì, đánh đổi gì* — mục **Trước khi bắt đầu**, ngay dưới đây.\n",
"\n",
"### 📖 Thuật ngữ\n",
"\n",
"| Thuật ngữ | Nghĩa |\n",
"|---|---|\n",
"| **ham / spam** | tin thật / tin rác-lừa đảo. Trong code: 0 = ham, 1 = spam |\n",
"| **đặc trưng** (feature) | một con số mô tả tin nhắn (ở đây: điểm của từng từ) |\n",
"| **học / thi** (train / test) | phần dữ liệu cho máy học / phần giữ lại để chấm, máy chưa từng thấy |\n",
"| **TP / TN / FP / FN** | bắt đúng spam / cho qua đúng ham / **chặn nhầm** ham / **bỏ lọt** spam |\n",
"| **precision** | trong các tin máy **chặn**, bao nhiêu phần đúng là spam (cao = ít chặn nhầm) |\n",
"| **recall** | trong các tin spam **thật**, máy bắt được bao nhiêu phần (cao = ít bỏ lọt) |\n",
"| **F1** | một con số gộp precision và recall (cao khi cả hai cùng cao) |\n",
"| `.shape` | kích thước bảng / ma trận: (số hàng, số cột) |"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Trước khi bắt đầu — Chọn mô hình nào? Khi nào dùng gì, đánh đổi gì\n",
"\n",
"*(Đọc thêm — để tra lại khi cần. Lần đầu có thể đọc lướt, chạy xong bài rồi quay lại.)*\n",
"\n",
"\n",
"**Không có mô hình \"tốt nhất\" cho mọi bài toán** (*No Free Lunch*). Chọn mô hình = trả lời lần lượt **4 câu hỏi**:\n",
"\n",
"**① Có đáp án (nhãn) không?**\n",
"- **Có** nhãn từ quá khứ (email đã biết sạch / lừa đảo, file đã biết là mã độc) → **học có giám sát**: Naïve Bayes, Logistic Regression, cây quyết định, Random Forest, XGBoost, SVM.\n",
"- **Không** có nhãn, hoặc phải tìm cái **chưa từng thấy** (zero-day) → **học không giám sát**: Isolation Forest tìm điểm lạ, K-Means gom nhóm hành vi (ví dụ UBA: tách bot tự động khỏi thao tác của người mà không cần ai gắn nhãn).\n",
" ⚠️ Mô hình không giám sát chỉ nói \"**khác đám đông**\", không nói \"**xấu**\". Trên honeypot, đám đông chính là kẻ tấn công (Lab 3, 5.0).\n",
"- **Có rất ít** nhãn → bắt đầu bằng luật + không giám sát, rồi gom nhãn dần từ các cảnh báo đã được người phân tích xác nhận.\n",
"\n",
"**② Dữ liệu dạng gì?**\n",
"\n",
"| Dạng dữ liệu | Thường dùng | Trong khoá |\n",
"|---|---|---|\n",
"| **Bảng số** (mỗi đối tượng = một hàng số) | mô hình cây: cây quyết định, Random Forest, XGBoost · Logistic Regression làm mốc so sánh | Lab 3 (mỗi IP 7 số), M5, Lab 7 (hồ sơ hành vi), M8–M10 |\n",
"| **Văn bản** (email, tin nhắn, log dạng chữ) | Naïve Bayes / Logistic Regression trên số đếm từ (TF-IDF) · mô hình ngôn ngữ (BERT, LLM) khi cần hiểu ngữ cảnh | M1–M2 (Naïve Bayes), **bài này**, Lab 10 |\n",
"| **Chuỗi theo thời gian** (số kết nối mỗi giờ, CPU mỗi phút, chuỗi lời gọi API của một chương trình) | phân rã chuỗi thời gian (xu hướng + nhịp ngày + phần dư) · HMM, LSTM khi cần học **thứ tự** sự kiện | M4.2, Lab 6 Phần 3 |\n",
"| **Tệp nhị phân** (mã độc) | trích đặc trưng (kích thước, entropy…) rồi dùng mô hình cây · đổi file thành ảnh + CNN là hướng **nghiên cứu**, cần nhiều dữ liệu và GPU | M5 |\n",
"\n",
"**③ Sợ bỏ lọt hay sợ báo nhầm?**\n",
"- Sợ **bỏ lọt** (mã độc, xâm nhập, lỗ hổng khi pentest) → ưu tiên **recall**. Sợ **báo nhầm** (chặn email khách hàng, khoá tài khoản nhân viên) → ưu tiên **precision**.\n",
"- ⚠️ **Hiểu lầm hay gặp:** \"muốn precision cao thì chọn Naïve Bayes / SVM\". Thực ra gần như mọi mô hình đều ra một **điểm / xác suất**, và **ngưỡng cắt** điểm đó mới quyết định đánh đổi: hạ ngưỡng → bắt thêm nhưng báo nhầm thêm (bài này 6.3 và 9.3; Lab 3 9E.3). Chọn mô hình **tốt** rồi chọn **ngưỡng** theo sức đội SOC.\n",
"- Xác suất của Naïve Bayes thường **quá tự tin** (sát 0 hoặc 1); SVM mặc định không ra xác suất → muốn đặt ngưỡng theo xác suất phải **hiệu chỉnh** (calibration) trước.\n",
"- Tìm bất thường / gom nhóm: không có đáp án để tính precision, recall → dùng điểm bất thường, độ tách nhóm (Silhouette), và quan trọng nhất là **người phân tích xem lại top N** (Lab 3, 5.6).\n",
"\n",
"**④ Ràng buộc khi chạy thật**\n",
"- **Giải thích được:** cần báo cáo kiểm toán / ban giám đốc → **hộp trắng** (luật, cây quyết định nông, Logistic Regression, Naïve Bayes: đọc được nhánh IF/ELSE hoặc trọng số từ). Cần bắt kẻ tinh vi (APT, tấn công \"chậm mà chắc\") → chấp nhận **hộp đen** (Random Forest nhiều cây, XGBoost, deep learning) rồi giải trình bằng **SHAP** (Lab 3 Phần 7, Lab 6 4.2).\n",
"- **Tốc độ / phần cứng:** router, IoT, máy trạm (RAM < 1 GB, CPU yếu) → luật, cây nông, Logistic Regression, Isolation Forest nhỏ. Trung tâm dữ liệu / cloud có GPU → deep learning, LLM.\n",
"- **Mất cân bằng lớp** (tấn công hiếm): **không** mô hình nào tự xử lý — phải chỉnh `class_weight` hoặc ngưỡng. Đo ở Lab 3 (9E): tấn công chỉ 5% → Random Forest mặc định bắt được 65%, thêm `class_weight=\"balanced\"` → 81%, đổi lại báo nhầm tăng từ 3 lên 12 IP. Bài này đo ở 6.2: `class_weight=\"balanced\"` bớt bỏ lọt tin rác, đổi lại chặn nhầm tin thật tăng.\n",
"- **Bị né:** mô hình dựa vào đặc trưng mà kẻ tấn công **tự đổi được** (tên phần mềm, tốc độ thử) thì dễ bị né (bài này Phần 10: chèn \"từ an toàn\"; Lab 3 Phần 8).\n",
"\n",
"**Bảng tra nhanh: khi nào dùng gì, đánh đổi gì**\n",
"\n",
"| Công cụ | Dùng khi | Được | Phải chấp nhận | Trong khoá |\n",
"|---|---|---|---|---|\n",
"| **Luật / ngưỡng** (IF/ELSE) | kiểu tấn công **đã biết rõ** | nhanh, không cần dữ liệu học, ai cũng hiểu | chỉ bắt đúng cái đã viết; dễ bị né (thử chậm, Lab 3 1.2) | M1, **bài này Phần 1** |\n",
"| **Naïve Bayes** | văn bản, ít dữ liệu, cần nhanh | học rất nhanh, chạy được trên máy yếu, xem được từ nào \"nặng ký\" | coi các từ độc lập với nhau; xác suất quá tự tin; bị lách bằng cách đổi từ | M1–M2, **bài này Phần 7** |\n",
"| **Logistic Regression** | cần mốc so sánh đơn giản, cần giải thích bằng trọng số | nhanh, ổn định, xác suất dùng được | chỉ vẽ được ranh giới \"thẳng\"; quan hệ phức tạp thì kém | **bài này Phần 6** |\n",
"| **SVM** | dữ liệu vừa, nhiều chiều (văn bản) | tách tốt khi ít mẫu | chậm khi dữ liệu lớn; mặc định không ra xác suất; khó giải thích | **bài này Phần 4–5** |\n",
"| **Perceptron** | mốc đơn giản nhất: \"cộng điểm từng từ\" | cực đơn giản, nhanh, tự tính tay được (3.1) | chỉ ranh giới thẳng; không ra xác suất; dễ bị lách bằng cách đổi từ (10.1) | **bài này Phần 3** |\n",
"| **Hội đồng** (Voting) | đã có vài mô hình khá, muốn bù chỗ sai cho nhau | thường ổn định hơn từng mô hình | chậm hơn, khó giải thích hơn; chia phiếu đều **không** tự động hơn mô hình giỏi nhất (8.2) | **bài này Phần 8** |\n",
"| **Cây quyết định** | cần luật đọc được cho người không chuyên | ra đúng dạng IF/ELSE | cây sâu dễ \"học thuộc lòng\" (overfit); dữ liệu đổi chút là cây đổi | Lab M2 (M2.3), M5 |\n",
"| **Random Forest** | dữ liệu bảng **có nhãn**, cần ổn định | chính xác, ít phải tinh chỉnh, ít overfit hơn một cây | khó đọc từng quyết định (cần SHAP / độ quan trọng đặc trưng); nặng hơn một cây | Lab 3 Phần 9, M8–M10 |\n",
"| **XGBoost** | dữ liệu bảng lớn, cần chính xác nhất | thường đứng đầu trên dữ liệu bảng; chạy được GPU | nhiều tham số phải chỉnh, chỉnh ẩu dễ overfit; khó giải thích | M5, Lab 10 5.1 |\n",
"| **Isolation Forest** | **không có nhãn**, tìm cái lạ | không cần ai gắn nhãn; ra **thứ hạng ưu tiên** | \"lạ\" ≠ \"xấu\"; dữ liệu bẩn làm lệch (Lab 3 Phần 6); phải chọn `contamination` nếu dùng `predict` (Lab 3 5.6) | Lab 3 Phần 5, Lab 6 Phần 4, Lab 7 Phần 7 |\n",
"| **K-Means** | gom nhóm hành vi (người dùng, IP) | thấy được \"các kiểu\" hành vi | phải chọn trước số nhóm k; phải chuẩn hoá; chỉ hợp với nhóm \"tròn\", cỡ gần nhau | Lab 6 Phần 6, Lab 7 |\n",
"| **Phân rã chuỗi thời gian** | số liệu theo giờ / ngày có nhịp lặp | tách nhịp bình thường khỏi đột biến | cần chuỗi đủ dài; nhịp thay đổi thì phải tính lại | M4.2, Lab 6 Phần 3 |\n",
"| **Deep learning** (LSTM, CNN) | dữ liệu rất lớn, dạng chuỗi / ảnh | học được mẫu phức tạp | cần rất nhiều dữ liệu và GPU; hộp đen | Lab 10 5.1 (mạng nơ-ron MLP nhỏ) |\n",
"| **LLM** | đọc, tóm tắt, giải thích log / cảnh báo | viết báo cáo nhanh | có thể bịa; tốn tiền; **không** để LLM tự quyết định chặn | Lab 3 Phần 10 |\n",
"\n",
"> **Tóm lại:** chọn mô hình là bài toán **cân đối** (thiết kế cả hệ thống, không chỉ chọn một thuật toán): biết mình **có dữ liệu gì** (có nhãn không, dạng bảng / chữ / chuỗi) và **cần tối ưu điều gì** (bỏ lọt hay báo nhầm, giải thích hay tốc độ). Hệ thống thật hầu như luôn **kết hợp nhiều lớp** (luật + không giám sát + có giám sát) — như hội đồng ở Phần 8 và phân luồng 3 làn ở 6.3; quy tắc bàn tay ở Lab 3 Phần 11.\n",
"\n",
"📌 **Pipeline xử lý luôn là 7 bước** (chi tiết ở bảng đầu bài):\n",
"1. **Thu thập dữ liệu** — có nhãn hay không nhãn?\n",
"2. **Làm sạch dữ liệu**\n",
"3. **Chọn mô hình phù hợp** với bài toán (4 câu hỏi ① – ④ ở trên)\n",
"4. **Coding**\n",
"5. **Lưu ý khi coding** — những lỗi không báo lỗi\n",
"6. **Đánh giá các mô hình**\n",
"7. **Quy tắc bàn tay phải** ✋"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## Phần 0 — Thu thập dữ liệu: dữ liệu gì, lấy ở đâu, có nhãn chưa, có cần làm sạch?\n",
"\n",
"📍 **Bước 1 — Thu thập dữ liệu** (0.1) và **Bước 2 — Làm sạch** (0.2).\n",
"\n",
"**5 câu hỏi về dữ liệu — trả lời trước khi viết dòng code nào:** (nguồn: file `SMSSpamCollection_readme.txt` đi kèm)\n",
"\n",
"| Câu hỏi | Trả lời cho dữ liệu bài này |\n",
"|---|---|\n",
"| **Dữ liệu gì?** | **văn bản**: mỗi dòng một tin nhắn SMS tiếng Anh, kèm nhãn. Bao nhiêu tin, bao nhiêu tin rác thì ô 0.1 đếm |\n",
"| **Lấy ở đâu?** | bộ *SMS Spam Collection v.1* (T. A. Almeida & J. M. Gómez Hidalgo, 2011), gom từ 4 nguồn công khai cho nghiên cứu |\n",
"| **Thu thập thế nào?** | tin **rác**: chép tay từ diễn đàn Grumbletext (Anh), nơi người dùng đăng tin rác họ nhận được, và từ bộ SMS Spam Corpus v.0.1. Tin **thường**: sinh viên Đại học Quốc gia Singapore **tình nguyện** chia sẻ (biết trước tin sẽ công khai), luận án của C. Tagg, và SMS Spam Corpus v.0.1 |\n",
"| **Có nhãn chưa?** | **Có**: `ham` / `spam` do **tác giả bộ dữ liệu** gắn theo nguồn lấy tin → dùng được học **có giám sát** |\n",
"| **Có cần làm sạch?** | **Có**: bản sao (tin rác gửi hàng loạt, 0.2), viết tắt và ký hiệu tiền tệ (sẽ thấy ngay ở 0.1). Đọc file đúng bảng mã UTF-8, nếu không ký tự £ thành \"£\" (0.1) |\n",
"\n",
"Giờ đọc file và đếm (0.1), rồi bỏ bản sao (0.2).\n",
"\n",
"### 0.1 Đọc và xem tin nhắn\n",
"\n",
"Cả bài chỉ dùng **một file**: `/opt/csai/labs/day4_phishing/SMSSpamCollection`. Mỗi dòng là một tin nhắn gồm 2 phần: **nhãn** (`ham` = tin thường, `spam` = tin rác / lừa đảo) và **nội dung**. Ô dưới đọc file thành bảng, rồi đếm có bao nhiêu tin thường, bao nhiêu tin rác."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import time\n",
"import warnings\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"%matplotlib inline\n",
"pd.set_option(\"display.max_colwidth\", 120)\n",
"pd.set_option(\"display.width\", 200)\n",
"RANDOM_STATE = 42 # \"hạt giống\" ngẫu nhiên: cố định để lần nào chạy cũng ra cùng kết quả\n",
"\n",
"# File gốc: mỗi dòng = nhãn <TAB> nội dung tin nhắn. sep=\"\\t\" = cột cách nhau bởi dấu TAB;\n",
"# quoting=3 = KHÔNG coi dấu nháy \" là đặc biệt (tin nhắn thật có rất nhiều dấu nháy);\n",
"# encoding=\"utf-8\" = bảng mã của file (đọc sai bảng mã thì ký tự £ thành \"£\")\n",
"du_lieu = pd.read_csv(\"/opt/csai/labs/day4_phishing/SMSSpamCollection\", sep=\"\\t\", header=None,\n",
" names=[\"nhan\", \"noi_dung\"], quoting=3, encoding=\"utf-8\")\n",
"# 📦 du_lieu = bảng 2 cột: nhan (ham / spam), noi_dung (tin nhắn)\n",
"print(\"du_lieu.shape =\", du_lieu.shape, \"-> (số tin nhắn, số cột)\")\n",
"dem = du_lieu.nhan.value_counts() # đếm số tin theo từng nhãn\n",
"print(f\"Có {len(du_lieu):,} tin: {dem['ham']:,} tin thường (ham) + {dem['spam']:,} tin rác (spam)\")\n",
"print(f\"Tỉ lệ spam: {(du_lieu.nhan == 'spam').mean():.1%}\")\n",
"print(\"\\n3 tin THẬT (ham) và 3 tin RÁC (spam) đầu tiên:\")\n",
"print(pd.concat([du_lieu[du_lieu.nhan == \"ham\"].head(3), du_lieu[du_lieu.nhan == \"spam\"].head(3)]).to_string())"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"**Kết quả:** `du_lieu.shape = (5574, 2)` · `Có 5,574 tin: 4,827 tin thường (ham) + 747 tin rác (spam)` · `Tỉ lệ spam: 13.4%`, rồi 3 tin thật (`Go until jurong point...`, `Ok lar... Joking wif u oni...`) và 3 tin rác (`Free entry in 2 a wkly comp to win FA Cup final...`, `WINNER!! ... £900 prize reward! To claim call 09061701461...`).\n",
"\n",
"📦 **`du_lieu`** = bảng 5.574 tin, 2 cột `nhan`, `noi_dung` · **`dem`** = số tin theo nhãn.\n",
"\n",
"👉 **Hiểu:** tin thật viết tắt, đời thường (\"wif u\", \"lor\"); tin rác có **giải thưởng, số điện thoại, mã, giá tiền**. Cứ **8 tin thì 1 tin rác** — đây là bài toán **mất cân bằng lớp**, sẽ quan trọng ở Phần 6 và 9."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 0.2 Làm sạch dữ liệu: cái bẫy bản sao\n",
"\n",
"Tin rác được gửi **hàng loạt**, nên cùng một nội dung xuất hiện nhiều lần. Nếu chia học / thi ngẫu nhiên khi còn bản sao, một tin trong đề thi có thể **đã nằm sẵn** trong phần học → máy chỉ cần \"nhớ\" là đúng, điểm cao **ảo** (đúng cái bẫy \"100%\" của bài M1.3). Ta đếm bản sao, rồi **bỏ bản sao trước khi chia**."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from sklearn.model_selection import train_test_split\n",
"so_ban_sao = du_lieu.noi_dung.duplicated().sum() # duplicated() = True cho tin trùng với một tin phía trên\n",
"print(\"Số tin là bản sao của tin khác:\", so_ban_sao, \"| trong đó spam:\", du_lieu[du_lieu.nhan == \"spam\"].noi_dung.duplicated().sum())\n",
"# Thử chia ngẫu nhiên KHI CÒN bản sao: bao nhiêu tin đề thi đã có sẵn trong phần học?\n",
"hoc_tam, thi_tam = train_test_split(du_lieu.index, test_size=0.25, random_state=RANDOM_STATE, stratify=du_lieu.nhan)\n",
"lo_de = du_lieu.noi_dung[thi_tam].isin(set(du_lieu.noi_dung[hoc_tam])).sum()\n",
"print(f\"Nếu không bỏ bản sao: {lo_de} tin trong đề thi ĐÃ CÓ trong phần học (lộ đề)\")\n",
"\n",
"tin = du_lieu.drop_duplicates(\"noi_dung\").reset_index(drop=True) # giữ 1 bản cho mỗi nội dung\n",
"y = (tin.nhan == \"spam\").astype(int) # nhãn dạng số: 1 = spam, 0 = ham\n",
"# 📦 tin = bảng tin nhắn KHÔNG trùng (dùng cho cả bài) · y = nhãn 0/1 cùng thứ tự với tin\n",
"print(f\"\\nSau khi bỏ bản sao: {len(tin)} tin, {y.sum()} spam ({y.mean():.1%})\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"**Kết quả:** `Số tin là bản sao của tin khác: 403 | trong đó spam: 94` · `Nếu không bỏ bản sao: 161 tin trong đề thi ĐÃ CÓ trong phần học (lộ đề)` · `Sau khi bỏ bản sao: 5171 tin, 653 spam (12.6%)`\n",
"\n",
"📦 **`tin`** = bảng tin nhắn không trùng (dùng cho cả bài) · **`y`** = nhãn 0/1 cùng thứ tự.\n",
"\n",
"👉 **Hiểu:** dữ liệu **thật** cũng có bản sao (tin rác gửi hàng loạt). Không bỏ thì 161 câu trong đề thi đã có đáp án sẵn — điểm sẽ cao **ảo**. Luôn kiểm tra bản sao **trước** khi chia."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 0.3 Vậy đây là bài toán gì? Chọn cách giải nào?\n",
"\n",
"📍 **Bước 3 — Chọn mô hình phù hợp với bài toán.**\n",
"\n",
"Một tin đến → **chặn** hay **cho qua**: bài **phân loại 2 lớp** trên **văn bản có nhãn** → **học có giám sát**, phải **biến chữ thành số** trước (Phần 2). Tin rác chiếm khoảng 1/8 → **mất cân bằng lớp**: không chỉ nhìn accuracy (Phần 6, 9). Cách cũ (luật từ khóa) là **mốc** mà mô hình nào cũng phải vượt (Phần 1).\n",
"\n",
"**Nếu thay bằng dữ liệu của chính bạn** (SMS / email của công ty, tiếng Việt) thì phải làm thêm gì? Đi lại đúng pipeline:\n",
"\n",
"| Bước | Bộ dữ liệu bài này (tải sẵn trên mạng) | Dữ liệu của bạn |\n",
"|---|---|---|\n",
"| 1. Thu thập | đã gom sẵn, đã công khai | xin phép, **che thông tin cá nhân** (số điện thoại, tên, số tài khoản) trước khi đưa vào máy học; gom đủ cả tin thường lẫn tin rác |\n",
"| Nhãn | **có sẵn** (tác giả gắn theo nguồn) | **phải tự gắn**: ai gắn (người phân tích, hay người dùng bấm \"báo cáo rác\"), quy tắc gắn viết rõ, cho 2 người cùng gắn một phần để xem có lệch nhau không. Nhãn sai thì mô hình học sai |\n",
"| 2. Làm sạch | bỏ bản sao (0.2) | bản sao + **chuẩn hoá chữ tiếng Việt**: một kiểu mã Unicode, có dấu / không dấu (2.1b) |\n",
"| 3. Chọn mô hình | TF-IDF + 5 ứng viên | vẫn các ứng viên đó, nhưng chữ tiếng Việt phải **tách từ** trước (`underthesea`, `pyvi`) hoặc lấy cặp từ `ngram_range=(1, 2)` (2.1b); cần hiểu ngữ cảnh thì dùng mô hình ngôn ngữ tiếng Việt |\n",
"| 4 – 6. Coding, lưu ý, đánh giá | số đo trong bài | **huấn luyện và đo lại** trên dữ liệu của bạn: con số của bài này **không mang sang** được; tỉ lệ tin rác khác thì phải chọn lại **ngưỡng** (9.3) |\n",
"\n",
"👉 **Hiểu:** chọn cách giải **sau khi** đã trả lời 5 câu hỏi về dữ liệu (đầu Phần 0). Đổi sang dữ liệu khác thì **cả pipeline chạy lại từ bước 1**, không chỉ thay file."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## Phần 1 — Luật cứng (từ khóa) thất bại thế nào?\n",
"\n",
"📍 **Bước 3 — Chọn mô hình (mốc so sánh):** trước khi dùng máy học, đo xem cách cũ làm được đến đâu. Mô hình nào cũng phải vượt mốc này.\n",
"\n",
"Cách cũ: viết tay danh sách từ khóa, tin nào chứa một từ trong danh sách → chặn. Ta chạy luật đó trên **toàn bộ** 5.171 tin.\n",
"\n",
"Luật so khớp thế nào (đọc trước khi sửa `TU_KHOA`):\n",
"- Tin SMS **không có tiêu đề**: luật dò trong **toàn bộ nội dung** tin nhắn.\n",
"- **Không phân biệt hoa / thường**: cả tin nhắn lẫn từ khóa đều được đổi sang chữ thường trước khi so, nên `\"Divorce\"` và `\"divorce\"` như nhau.\n",
"- So theo **chuỗi con**: từ khóa `\"win\"` khớp cả `\"wine\"`, `\"window\"`, `\"wins\"` (59 tin thường chứa chuỗi `win`) — một nguồn chặn nhầm."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ✏️ SỬA: danh sách từ khóa. Thêm / bớt rồi chạy lại để xem chặn nhầm / bỏ lọt thay đổi thế nào\n",
"TU_KHOA = [\"free\", \"win\", \"winner\", \"prize\", \"call now\", \"claim\", \"urgent\", \"cash\", \"txt\", \"reply\"]\n",
"tu_khoa_thuong = [k.lower() for k in TU_KHOA] # đổi từ khóa sang chữ thường (ai gõ \"Divorce\" cũng vẫn khớp)\n",
"chu_thuong = tin.noi_dung.str.lower() # đổi cả nội dung tin sang chữ thường\n",
"bi_chan = chu_thuong.apply(lambda t: any(k in t for k in tu_khoa_thuong)).astype(int) # 1 = nội dung chứa ít nhất 1 từ khóa\n",
"chan_nham = ((bi_chan == 1) & (y == 0)).sum()\n",
"bo_lot = ((bi_chan == 0) & (y == 1)).sum()\n",
"print(f\"Luật {len(TU_KHOA)} từ khóa: chặn {bi_chan.sum()} tin | CHẶN NHẦM {chan_nham} tin thật | BỎ LỌT {bo_lot} tin rác (trên {y.sum()} tin rác)\")\n",
"print(\"\\nVí dụ tin THẬT bị chặn nhầm:\")\n",
"print(tin.noi_dung[(bi_chan == 1) & (y == 0)].head(4).to_string())\n",
"print(\"\\nVí dụ tin RÁC lọt qua:\")\n",
"print(tin.noi_dung[(bi_chan == 0) & (y == 1)].head(4).to_string())"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"**Kết quả:** `Luật 10 từ khóa: chặn 643 tin | CHẶN NHẦM 189 tin thật | BỎ LỌT 199 tin rác (trên 653 tin rác)`, ví dụ chặn nhầm: `I am waiting machan. Call me once you free.`, `Aight, I'll hit you up when I get some cash`; ví dụ lọt: `Did you hear about the new \"Divorce Barbie\"?...`, `Customer service annoncement. You have a New Years delivery waiting for you. Please call 07046744435 now...`, `Are you unique enough? ... www.areyouunique.co.uk`.\n",
"\n",
"📦 **`TU_KHOA`** = danh sách từ khóa (sửa được) · **`tu_khoa_thuong`** = cùng danh sách, đổi sang chữ thường · **`bi_chan`** = 1 nếu tin chứa từ khóa.\n",
"\n",
"👉 **Hiểu:** \"free\" trong *\"call me once you free\"* (rảnh) bị coi như \"free\" trong *\"FREE prize\"* (miễn phí) → **chặn nhầm 189 tin thật**. Tin rác không dùng đúng từ trong danh sách thì **lọt 199 tin** (gần 1/3). Thêm từ khóa thì bắt thêm nhưng cũng chặn nhầm thêm — vòng luẩn quẩn của luật cứng.\n",
"\n",
"**Thử sửa** (đã đo): thêm `\"Divorce\"` vào `TU_KHOA` → `chặn 645 | CHẶN NHẦM 190 | BỎ LỌT 198`: bắt thêm đúng **1** tin rác (*\"Divorce Barbie\"*) nhưng chặn nhầm luôn tin thật *\"We got a divorce. Lol...\"*. Cả 5.171 tin chỉ có 2 tin chứa chữ này. Thêm cả `\"unique \"` và `\"divorce\"` → `chặn 648 | CHẶN NHẦM 191 | BỎ LỌT 196`: bắt thêm 3 tin rác, chặn nhầm thêm 2 tin thật (một tin thật viết *\"...a unique person like u...\"*). Mỗi từ khóa mới chỉ vá **một** lỗ, và thường mở ra một lỗ khác."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## Phần 2 — Biến chữ thành số, chia học / thi\n",
"\n",
"📍 **Bước 4 — Coding:** biến chữ thành số, rồi chia dữ liệu thành phần học và phần thi. 2.2 là một **lưu ý khi coding** (bước 5): từ điển chỉ được học từ phần học.\n",
"\n",
"Máy học chỉ tính được trên **số**. **TF-IDF** (*Term Frequency – Inverse Document Frequency*) là cách kinh điển để biến văn bản thành số: mỗi tin nhắn thành **một hàng số**, mỗi cột là **một từ** trong từ điển.\n",
"\n",
"**Khái niệm** — điểm của một từ trong một tin = **TF × IDF**:\n",
"- **TF** (tần suất trong tin): từ lặp lại nhiều trong tin này → điểm cao hơn;\n",
"- **IDF** (độ hiếm trên mọi tin): từ xuất hiện ở **hầu hết** tin (\"you\", \"the\") → điểm bị hạ; từ hiếm (\"prize\", \"claim\") → điểm cao.\n",
"\n",
"**Thuật toán** (4 bước, máy làm hết):\n",
"1. **Tách từ**: đổi chữ thường, cắt câu thành từ (bỏ dấu câu, bỏ từ chỉ 1 ký tự).\n",
"2. **Lập từ điển**: gom mọi từ khác nhau trong dữ liệu học, mỗi từ một cột.\n",
"3. **Tính điểm**: TF = số lần từ xuất hiện trong tin; IDF ≈ log(số tin ÷ số tin có từ đó) (scikit-learn cộng thêm 1 để không bao giờ ra 0): từ càng phổ biến, IDF càng nhỏ.\n",
"4. **Chuẩn hoá** mỗi hàng về cùng độ dài, để tin dài không \"nặng ký\" hơn tin ngắn.\n",
"\n",
"**Thư viện**: `TfidfVectorizer` của **scikit-learn** làm cả 4 bước: `fit` = học từ điển + IDF, `transform` = đổi văn bản thành số.\n",
"\n",
"**Ứng dụng**: lọc thư rác / lừa đảo (bài này), máy tìm kiếm xếp hạng tài liệu, phân loại log / câu lệnh, tìm văn bản giống nhau, rút từ khoá.\n",
"**Giới hạn**: TF-IDF chỉ **đếm từ**, không hiểu **thứ tự và nghĩa** (\"not free\" và \"free\" gần như như nhau); từ chưa có trong từ điển bị bỏ qua. Cần hiểu ngữ cảnh thì dùng mô hình ngôn ngữ (BERT, LLM).\n",
"\n",
"### 2.1 Thử TF-IDF trên 3 tin\n",
"\n",
"**Ô này để làm gì?** Biến chữ thành số là bước **bắt buộc**: mọi mô hình từ Phần 3 trở đi chỉ nhận **bảng số**. Ô này là bản **thu nhỏ** (3 câu) để bạn **nhìn tận mắt** bảng số đó trông thế nào và vì sao từ hiếm được điểm cao; 2.2 làm thật trên 5.171 tin."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from sklearn.feature_extraction.text import TfidfVectorizer\n",
"# ✏️ SỬA: 3 câu thử (tiếng Anh, như dữ liệu; tiếng Việt: xem 2.1b)\n",
"cau = [\"WIN a FREE prize now, call to claim\", \"are you free now? can i call you\", \"see you at home tonight\"]\n",
"thu = TfidfVectorizer()\n",
"bang = thu.fit_transform(cau) # học từ điển từ 3 câu, biến mỗi câu thành 1 hàng số\n",
"print(\"Từ điển:\", list(thu.get_feature_names_out()))\n",
"print(pd.DataFrame(bang.toarray().round(2), columns=thu.get_feature_names_out(), index=[\"câu 1\", \"câu 2\", \"câu 3\"]).to_string())"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"**Kết quả:** từ điển 14 từ, rồi bảng 3 hàng × 14 cột. Câu 1 có điểm ở `claim, prize, win, to` (0,42) và `call, free, now` (0,32); câu 2 có `you` (0,62), `are, can` (0,41).\n",
"\n",
"👉 **Hiểu:** `claim`, `prize`, `win` chỉ có trong câu 1 → điểm **cao** (0,42); `call`, `free`, `now` có cả ở câu 1 và câu 2 → điểm **thấp hơn** (0,32) — đó là IDF \"phạt\" từ phổ biến. Mỗi câu giờ là **một hàng số** máy tính toán được."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 2.1b (Tuỳ chọn) Còn tin nhắn tiếng Việt thì sao?\n",
"\n",
"`TfidfVectorizer` đọc được chữ có dấu, nhưng tiếng Việt có 3 điểm cần biết:\n",
"- Nó cắt theo **dấu cách**, mà tiếng Việt cách nhau theo **âm tiết** → \"chuyển khoản\" thành 2 từ rời \"chuyển\" và \"khoản\". Cách sửa nhanh: `ngram_range=(1, 2)` để lấy thêm **cặp từ** liền nhau; cách chuẩn: dùng thư viện **tách từ** tiếng Việt (ví dụ `underthesea`, `pyvi`) trước khi đưa vào TF-IDF.\n",
"- Từ **1 ký tự** bị bỏ (\"ở\", \"à\", \"ạ\").\n",
"- Cùng một chữ có thể được gõ theo **2 kiểu mã Unicode** (dựng sẵn / tổ hợp) hoặc **không dấu** (\"chuyen khoan\") → máy coi là **từ khác nhau**. Phải **chuẩn hoá** về một kiểu (`unicodedata.normalize(\"NFC\", ...)`) trước."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import unicodedata\n",
"# ✏️ SỬA: thử tin nhắn tiếng Việt của bạn\n",
"cau_viet = [\"Chúc mừng bạn trúng thưởng 50 triệu, bấm link để nhận ngay\",\n",
" \"Tối nay mình ở nhà, mai chuyển khoản cho bạn nhé\",\n",
" \"Tài khoản của bạn bị khoá, chuyển khoản phí 200k để mở lại\"]\n",
"tach_don = TfidfVectorizer().fit(cau_viet) # mặc định: mỗi âm tiết là một \"từ\"\n",
"print(\"Từ điển (từ đơn):\", list(tach_don.get_feature_names_out()))\n",
"tach_cap = TfidfVectorizer(ngram_range=(1, 2)).fit(cau_viet) # thêm cặp 2 âm tiết liền nhau\n",
"print(\"\\nVài cặp từ khi ngram_range=(1, 2):\", [w for w in tach_cap.get_feature_names_out() if \" \" in w][:12])\n",
"\n",
"# Cùng chữ \"chuyển khoản\" nhưng 3 cách gõ: dựng sẵn (NFC), tổ hợp (NFD), không dấu\n",
"ba_kieu = [\"chuyển khoản\", unicodedata.normalize(\"NFD\", \"chuyển khoản\"), \"chuyen khoan\"]\n",
"print(\"\\n3 kiểu gõ ->\", list(TfidfVectorizer().fit(ba_kieu).get_feature_names_out()))\n",
"print(\"Sau khi chuẩn hoá NFC ->\", list(TfidfVectorizer().fit([unicodedata.normalize(\"NFC\", c) for c in ba_kieu]).get_feature_names_out()))"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"**Kết quả:**\n",
"- Từ điển từ đơn: 29 \"từ\", toàn **âm tiết rời**: `chuyển`, `khoản`, `chúc`, `mừng`, `trúng`, `thưởng`… — **không có `ở`** (1 ký tự bị bỏ).\n",
"- Với `ngram_range=(1, 2)`: có thêm cặp `chuyển khoản`, `chúc mừng`, `bấm link`, `bị khoá`, `50 triệu`…\n",
"- 3 kiểu gõ \"chuyển khoản\" → `['chuye', 'chuyen', 'chuyển', 'khoa', 'khoan', 'khoản']`; sau khi chuẩn hoá NFC → `['chuyen', 'chuyển', 'khoan', 'khoản']`.\n",
"\n",
"📦 **`cau_viet`** = 3 câu thử (sửa được) · **`tach_don`**, **`tach_cap`** = hai bộ TF-IDF (từ đơn / thêm cặp từ) · **`ba_kieu`** = 3 cách gõ cùng một chữ.\n",
"\n",
"👉 **Hiểu:**\n",
"- **Tiếng Việt khác tiếng Anh ở đâu khi xử lý?**\n",
"\n",
"| | Tiếng Anh | Tiếng Việt |\n",
"|---|---|---|\n",
"| Dấu cách tách ra | **từ** (`prize`, `claim`) | **âm tiết**; một từ thường 2 – 3 âm tiết (`chuyển khoản`, `tài khoản`) |\n",
"| Dấu | gần như không có | dấu thanh, dấu mũ / móc; 2 kiểu mã Unicode; nhiều người gõ **không dấu** |\n",
"| Từ 1 ký tự | ít quan trọng (`a`, `I`) | có nghĩa (`ở`, `à`, `ạ`) nhưng bị bỏ mặc định |\n",
"| Chuẩn bị trước TF-IDF | dùng ngay | chuẩn hoá NFC → tách từ (`underthesea`, `pyvi`) → TF-IDF có cặp từ |\n",
"| Dữ liệu có nhãn | nhiều bộ công khai (như bộ của bài này) | ít bộ công khai hơn, thường phải tự gom và gắn nhãn |\n",
"\n",
"- Tách theo âm tiết thì \"chuyển\" (trong *chuyển khoản*) và \"chuyển\" (trong *chuyển nhà*) là **một cột** — mất nghĩa. Thêm **cặp từ** giữ lại được cụm đáng ngờ như `chuyển khoản`, `bấm link`, `bị khoá`.\n",
"- Kiểu gõ **tổ hợp** (NFD) làm chữ vỡ thành `chuye`, `khoa`: máy không nhận ra là cùng một từ. Chuẩn hoá NFC sửa được; còn **không dấu** (`chuyen`) vẫn là từ khác → tin lừa đảo gõ không dấu sẽ né được bộ lọc học trên tin có dấu, trừ khi bạn **bỏ dấu cả hai** hoặc thêm dữ liệu không dấu.\n",
"- Làm bộ lọc tiếng Việt thật: **chuẩn hoá Unicode → tách từ tiếng Việt → TF-IDF có cặp từ**, và cần **dữ liệu tin nhắn tiếng Việt có nhãn**."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 2.2 Chia học / thi, rồi học từ điển CHỈ từ phần học\n",
"\n",
"**Dữ liệu học và thi ở đâu?** Không có file riêng nào cả: cả hai được **cắt ra từ chính bảng `tin`** (5.171 tin không trùng, tạo ở 0.2) ngay trong ô dưới đây:\n",
"\n",
"```\n",
"SMSSpamCollection (5.574 tin) --0.1 đọc--> du_lieu --0.2 bỏ bản sao--> tin (5.171 tin)\n",
" |-- 75% --> phần HỌC: máy được xem cả nội dung lẫn nhãn\n",
" '-- 25% --> phần THI: máy KHÔNG được học, chỉ dùng để chấm điểm\n",
"```\n",
"\n",
"`train_test_split` không chép dữ liệu, nó trả về **số thứ tự hàng** (vị trí) của từng phần trong bảng `tin`. Muốn xem một tin của phần học: `tin.loc[vi_tri_hoc[0]]`.\n",
"\n",
"**Vì sao 75 / 25? Đổi được không?** Được: sửa `TI_LE_THI`. Đây là một **đánh đổi**: phần học lớn → máy học tốt hơn; phần thi lớn → điểm chấm **ổn định** hơn, ít phụ thuộc may rủi. Thường chọn phần thi 20 – 30%. Đo thật với SVM (Phần 4), mỗi tỉ lệ chia ngẫu nhiên 10 lần:\n",
"\n",
"| phần thi | số tin thi (tin rác) | F1 trung bình | F1 thấp nhất – cao nhất |\n",
"|---|---|---|---|\n",
"| 10% | 518 (65) | 0,945 | 0,917 – 0,985 |\n",
"| 20% | 1.035 (131) | 0,939 | 0,911 – 0,973 |\n",
"| **25%** (bài này) | 1.293 (163) | 0,936 | 0,906 – 0,959 |\n",
"| 30% | 1.552 (196) | 0,936 | 0,911 – 0,961 |\n",
"| 50% | 2.586 (327) | 0,931 | 0,910 – 0,940 |\n",
"\n",
"Phần thi 10% chỉ có 65 tin rác: bắt sai vài tin là điểm nhảy mạnh (0,917 → 0,985), may thì đẹp, rủi thì xấu. Phần thi 50% chấm ổn định hơn nhưng máy chỉ còn học một nửa nên điểm trung bình thấp hơn. ⚠️ Đổi tỉ lệ thì **mọi con số \"Kết quả\" bên dưới sẽ khác** (chúng đo với 0,25). Lab 10 Phần 1 đi sâu cách chấm cho chắc (kiểm định chéo)."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ✏️ SỬA: tỉ lệ phần thi (0.25 = 25% để thi, 75% để học). stratify=y giữ cùng tỉ lệ spam ở 2 phần\n",
"TI_LE_THI = 0.25\n",
"vi_tri_hoc, vi_tri_thi = train_test_split(tin.index, test_size=TI_LE_THI, random_state=RANDOM_STATE, stratify=y)\n",
"print(f\"Bảng tin: {len(tin)} tin -> phần học {len(vi_tri_hoc)} tin ({y[vi_tri_hoc].sum()} spam) + phần thi {len(vi_tri_thi)} tin ({y[vi_tri_thi].sum()} spam)\")\n",
"print(\"5 vị trí đầu của phần học:\", list(vi_tri_hoc[:5]), \"| của phần thi:\", list(vi_tri_thi[:5]))\n",
"tfidf = TfidfVectorizer()\n",
"X_hoc = tfidf.fit_transform(tin.noi_dung[vi_tri_hoc]) # fit = HỌC từ điển từ phần học; transform = đổi thành số\n",
"X_thi = tfidf.transform(tin.noi_dung[vi_tri_thi]) # phần thi CHỈ transform bằng từ điển đã học (không fit lại)\n",
"y_hoc, y_thi = y[vi_tri_hoc], y[vi_tri_thi]\n",
"tu_dien = tfidf.get_feature_names_out()\n",
"# 📦 X_hoc, X_thi = ma trận số (mỗi hàng 1 tin, mỗi cột 1 từ) · y_hoc, y_thi = nhãn · tu_dien = danh sách từ\n",
"print(\"X_hoc.shape =\", X_hoc.shape, \"| X_thi.shape =\", X_thi.shape, \"-> (số tin, số từ trong từ điển)\")\n",
"print(f\"Phần thi: {len(y_thi)} tin, {y_thi.sum()} spam\")\n",
"print(f\"Mốc 'lười': đoán mọi tin là ham -> đúng {(y_thi == 0).mean():.1%} — nhưng không bắt được tin rác nào\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"**Kết quả:** `Bảng tin: 5171 tin -> phần học 3878 tin (490 spam) + phần thi 1293 tin (163 spam)` · `5 vị trí đầu của phần học: [196, 2980, 2025, 1674, 3260] | của phần thi: [3125, 2829, 2272, 3309, 136]` · `X_hoc.shape = (3878, 7395) | X_thi.shape = (1293, 7395)` · `Phần thi: 1293 tin, 163 spam` · `Mốc 'lười': đoán mọi tin là ham -> đúng 87.4% — nhưng không bắt được tin rác nào`\n",
"\n",
"📦 **`vi_tri_hoc`, `vi_tri_thi`** = số thứ tự hàng trong `tin` của phần học / phần thi · **`X_hoc`, `X_thi`** = ma trận số (mỗi hàng 1 tin, mỗi cột 1 trong 7.395 từ) · **`y_hoc`, `y_thi`** = nhãn · **`tu_dien`** = 7.395 từ · **`tfidf`** = bộ biến đổi đã học từ điển (dùng lại ở Phần 10).\n",
"\n",
"👉 **Hiểu:** **87,4%** là con số mọi mô hình phải vượt — một bộ lọc \"không làm gì\" đã đạt nó. Vì vậy **accuracy một mình không nói lên gì**; Phần 9 sẽ dùng precision / recall / F1."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## Phần 3 — Perceptron: \"anh bảo vệ\" cộng điểm\n",
"\n",
"📍 **Bước 4 — Coding, ứng viên 1/5.** Dữ liệu đã sạch (Phần 0), đã thành số và chia học / thi (Phần 2). Từ đây đến Phần 8 ta thử lần lượt **5 mô hình** trên **cùng** phần học, chấm trên **cùng** phần thi, để so công bằng. Perceptron **không phải bước bắt buộc**: nó là ứng viên **đơn giản nhất**, làm mốc cho các ứng viên sau.\n",
"\n",
"**Perceptron là gì?** Là mô hình máy học **đầu tiên và đơn giản nhất** (Frank Rosenblatt, 1958), mô phỏng **một tế bào thần kinh**: nhận nhiều tín hiệu, mỗi tín hiệu nhân với một **trọng số**, cộng lại; tổng vượt ngưỡng thì \"kích hoạt\" (báo spam).\n",
"\n",
"Hình dung **anh bảo vệ** ở cổng: mỗi dấu hiệu đáng ngờ (đeo khẩu trang kín, đi lúc 2 giờ sáng) **cộng điểm**, mỗi dấu hiệu đáng tin (đeo thẻ nhân viên) **trừ điểm**; tổng điểm vượt mức thì chặn lại hỏi.\n",
"\n",
"**Vì sao học Perceptron ở đây?**\n",
"1. Nó là bước **nâng cấp trực tiếp** của luật từ khóa ở Phần 1: vẫn là \"cộng điểm theo từ\", nhưng **máy tự chọn** từ nào đáng ngờ và nặng bao nhiêu, thay cho người viết tay danh sách.\n",
"2. Nó là **bộ khung chung** của các mô hình tiếp theo: SVM (Phần 4) và Logistic Regression (Phần 6) cũng tính **tổng có trọng số** như vậy, chỉ khác cách chọn trọng số và cách ra kết quả.\n",
"3. Nó là **viên gạch** của mạng nơ-ron và deep learning: mạng nơ-ron = rất nhiều \"nơ-ron\" giống Perceptron (thêm một hàm kích hoạt mềm hơn) nối thành nhiều lớp.\n",
"\n",
"**Cách tính:** mỗi từ có một **trọng số** (số dương = đáng ngờ, số âm = đáng tin). Với một tin nhắn:\n",
"\n",
"**điểm = (trọng số từ 1 × giá trị TF-IDF từ 1) + (trọng số từ 2 × ...) + ... + bias**\n",
"\n",
"Điểm > 0 → **spam**, ngược lại → **ham**. Chỉ có Có / Không, không có \"có lẽ\". **Học** = mỗi lần đoán sai, chỉnh trọng số một chút theo hướng sửa lỗi, lặp lại nhiều vòng."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from sklearn.linear_model import Perceptron\n",
"from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, confusion_matrix\n",
"\n",
"def do_luong(ten, du_doan):\n",
" \"\"\"Trả về 1 hàng kết quả: accuracy, precision, recall, F1, số chặn nhầm (FP), số bỏ lọt (FN).\"\"\"\n",
" tn, fp, fn, tp = confusion_matrix(y_thi, du_doan).ravel()\n",
" return {\"mô hình\": ten, \"accuracy\": accuracy_score(y_thi, du_doan), \"precision\": precision_score(y_thi, du_doan, zero_division=0), # zero_division=0: mô hình không chặn tin nào -> precision tính là 0\n",
" \"recall\": recall_score(y_thi, du_doan), \"F1\": f1_score(y_thi, du_doan), \"chặn nhầm (FP)\": fp, \"bỏ lọt (FN)\": fn}\n",
"\n",
"perceptron = Perceptron(random_state=RANDOM_STATE).fit(X_hoc, y_hoc)\n",
"# 📦 do_luong(ten, du_doan) = hàm chấm 1 mô hình trên phần thi (dùng lại ở mọi phần)\n",
"print(pd.DataFrame([do_luong(\"Perceptron\", perceptron.predict(X_thi))]).round(3).to_string(index=False))\n",
"\n",
"trong_so = perceptron.coef_[0] # 1 trọng số cho mỗi từ trong từ điển\n",
"print(\"\\n8 từ trọng số DƯƠNG lớn nhất (kéo về spam):\", list(tu_dien[np.argsort(trong_so)[-8:][::-1]]))\n",
"print(\"8 từ trọng số ÂM lớn nhất (kéo về ham) :\", list(tu_dien[np.argsort(trong_so)[:8]]))\n",
"print(\"bias =\", round(perceptron.intercept_[0], 3))\n",
"\n",
"# Tự tính \"điểm\" của 1 tin bằng tay theo công thức, so với máy\n",
"i = 0\n",
"x = X_thi[i] # hàng TF-IDF của tin thi đầu tiên\n",
"diem_tay = x.toarray()[0] @ trong_so + perceptron.intercept_[0] # @ = nhân từng cặp rồi cộng lại\n",
"print(f\"\\nTin: {tin.noi_dung[vi_tri_thi[i]]!r}\")\n",
"print(f\"Điểm tự tính = {diem_tay:.3f} | máy tính (decision_function) = {perceptron.decision_function(x)[0]:.3f} -> {'SPAM' if diem_tay > 0 else 'ham'}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"**Kết quả:**\n",
"```\n",
" mô hình accuracy precision recall F1 chặn nhầm (FP) bỏ lọt (FN)\n",
"Perceptron 0.976 0.912 0.896 0.904 14 17\n",
"8 từ trọng số DƯƠNG lớn nhất (kéo về spam): ['txt', 'ringtone', 'services', 'uk', 'darling', 'reply', '07090201529', '146tf150p']\n",
"8 từ trọng số ÂM lớn nhất (kéo về ham) : ['place', 'gd', 'mail', 'princess', 'didn', 'wearing', 'everything', 'drop']\n",
"bias = -0.29\n",
"Tin: \"RCT' THNQ Adrian for U text. Rgds Vatian\"\n",
"Điểm tự tính = 0.046 | máy tính (decision_function) = 0.046 -> SPAM\n",
"```\n",
"📦 **`do_luong(ten, du_doan)`** = hàm chấm 1 mô hình trên đề thi · **`perceptron`** = mô hình đã học · **`trong_so`** = 7.395 trọng số, mỗi từ một số.\n",
"\n",
"👉 **Hiểu:**\n",
"- **Đọc dòng kết quả** (trên 1.293 tin thi, 163 tin rác): accuracy **0,976** = đoán đúng 97,6% số tin, nhưng mốc lười đã 87,4% (2.2) nên con số này một mình nói ít. **precision 0,912** = trong 160 tin bị chặn, 146 tin đúng là rác: cứ khoảng 11 tin bị chặn thì có 1 tin thật bị **chặn nhầm** (FP = 14). **recall 0,896** = bắt được 146 / 163 tin rác, **bỏ lọt 17** (FN). **F1 0,904** gộp hai số trên. Đây là mốc mà 4 ứng viên sau phải vượt.\n",
"- Máy **tự** học ra các từ đáng ngờ (`txt`, `ringtone`, `reply`, cả số điện thoại `07090201529`) — không ai viết tay.\n",
"- Tự nhân - cộng theo công thức ra **0,046**, đúng bằng máy → Perceptron chỉ là **một phép cộng có trọng số**. Điểm 0,046 chỉ nhỉnh hơn 0 một chút mà vẫn bị phán **SPAM dứt khoát** — không có \"có lẽ\"."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## Phần 4 — SVM: ranh giới có vùng đệm rộng nhất\n",
"\n",
"📍 **Bước 4 — Coding, ứng viên 2/5.** Perceptron vượt xa mốc lười (F1 0,904) nhưng **chặn nhầm 14 tin thật**. Vì sao? Perceptron dừng lại ở **bất kỳ** đường ranh giới nào tách được dữ liệu, kể cả đường sát sạt vào vài tin. **SVM** (Support Vector Machine) chọn đường ranh giới **cách xa nhất** cả tin ham gần nhất lẫn tin spam gần nhất — khoảng cách đó gọi là **vùng đệm** (margin). Vùng đệm rộng → tin mới hơi khác một chút vẫn rơi đúng phía.\n",
"\n",
"Các tin **nằm sát ranh giới** (khó phân biệt nhất) quyết định vị trí ranh giới; chúng gọi là **support vectors**. Các tin khác có bỏ đi thì ranh giới cũng không đổi."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 4.0 Hình minh hoạ: ranh giới \"sát sạt\" và ranh giới có vùng đệm\n",
"\n",
"Văn bản có 7.395 cột nên không vẽ được. Để **nhìn thấy** ý tưởng, ô dưới dùng **dữ liệu giả 2 chiều** (40 chấm, chỉ để minh hoạ): cho Perceptron và SVM cùng tìm đường tách 2 nhóm.\n",
"\n",
"**Cách đọc hình:** chấm xanh = \"ham\", chấm đỏ = \"spam\"; **đường liền** = ranh giới mô hình tìm được; ở hình SVM, **2 đường nét đứt** = mép vùng đệm, **vòng xanh lá** = support vector (các chấm giữ ranh giới); tiêu đề = khoảng cách từ ranh giới tới chấm **gần nhất**."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from sklearn.svm import SVC\n",
"# Dữ liệu GIẢ 2 chiều, chỉ để vẽ minh hoạ: 20 chấm \"ham\" quanh (2, 2), 20 chấm \"spam\" quanh (5, 4.5)\n",
"ngau_nhien_gia = np.random.RandomState(3)\n",
"P = np.vstack([ngau_nhien_gia.normal([2, 2], 0.7, (20, 2)), ngau_nhien_gia.normal([5, 4.5], 0.7, (20, 2))])\n",
"nhan_gia = np.array([0] * 20 + [1] * 20)\n",
"per_gia = Perceptron(random_state=0, max_iter=1000).fit(P, nhan_gia)\n",
"svm_gia = SVC(kernel=\"linear\", C=1000).fit(P, nhan_gia) # C lớn = không cho chấm nào lọt vào vùng đệm\n",
"\n",
"def gan_nhat(w, b):\n",
" \"\"\"Khoảng cách từ ranh giới w·x + b = 0 tới chấm gần nhất.\"\"\"\n",
" return np.min(np.abs(P @ w + b)) / np.linalg.norm(w)\n",
"\n",
"xs = np.linspace(0, 7, 100)\n",
"hinh, o = plt.subplots(1, 2, figsize=(12, 5), sharey=True)\n",
"for truc, ten, m in zip(o, [\"Perceptron\", \"SVM\"], [per_gia, svm_gia]):\n",
" w, b = m.coef_[0], m.intercept_[0]\n",
" truc.scatter(P[:, 0], P[:, 1], c=np.where(nhan_gia == 1, \"red\", \"blue\"), s=30)\n",
" truc.plot(xs, -(w[0] * xs + b) / w[1], \"k-\", label=\"ranh giới\")\n",
" if ten == \"SVM\":\n",
" for lech in (-1, 1): # mép vùng đệm: w·x + b = ±1\n",
" truc.plot(xs, -(w[0] * xs + b + lech) / w[1], \"k--\", lw=1)\n",
" truc.scatter(m.support_vectors_[:, 0], m.support_vectors_[:, 1], s=220, facecolors=\"none\",\n",
" edgecolors=\"green\", lw=2, label=\"support vector\")\n",
" truc.set_title(f\"{ten}: ranh giới cách chấm gần nhất {gan_nhat(w, b):.2f}\")\n",
" truc.set_xlim(0, 7); truc.set_ylim(0, 7); truc.legend(loc=\"upper left\")\n",
"plt.tight_layout(); plt.show()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"**Kết quả:** 2 hình cạnh nhau, cùng 40 chấm giả. Trái `Perceptron: ranh giới cách chấm gần nhất 0.08` — đường dốc **gần như chạm** một chấm đỏ ở khoảng (3,4; 4,3). Phải `SVM: ranh giới cách chấm gần nhất 0.65` — đường nằm **chính giữa** 2 nhóm, 2 đường nét đứt hai bên, **3 support vector** (vòng xanh lá: 1 chấm xanh, 2 chấm đỏ) nằm đúng trên mép vùng đệm.\n",
"\n",
"📦 **`P`**, **`nhan_gia`** = 40 chấm giả và nhãn của chúng (chỉ dùng cho hình này) · **`per_gia`**, **`svm_gia`** = 2 mô hình học trên dữ liệu giả.\n",
"\n",
"👉 **Hiểu:**\n",
"- Cả hai đều tách **đúng 100%** 40 chấm, nhưng Perceptron dừng ngay khi hết lỗi, nên ranh giới của nó **sát sạt** một tin \"spam\": một tin mới hơi khác một chút là rơi sang phía sai.\n",
"- SVM chọn đường **cách xa nhất** cả hai nhóm (0,65 so với 0,08). Chỉ 3 chấm sát ranh giới quyết định vị trí đường; 37 chấm còn lại có bỏ đi thì đường cũng không đổi.\n",
"- 4.1 làm đúng việc này trên 7.395 chiều của tin nhắn thật, nơi ta không vẽ được nhưng đo được: chặn nhầm giảm từ 14 xuống 3."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 4.1 SVM trên tin nhắn thật"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from sklearn.svm import LinearSVC, SVC\n",
"svm = LinearSVC(random_state=RANDOM_STATE).fit(X_hoc, y_hoc)\n",
"print(pd.DataFrame([do_luong(\"SVM (tuyến tính)\", svm.predict(X_thi))]).round(3).to_string(index=False))\n",
"\n",
"# Đếm support vectors: SVC(kernel=\"linear\") cùng ý tưởng, cho biết các tin \"giữ ranh giới\"\n",
"svc = SVC(kernel=\"linear\").fit(X_hoc, y_hoc)\n",
"so_sv = svc.n_support_ # [số tin ham, số tin spam] là support vector\n",
"print(f\"\\nSupport vectors: {so_sv[0]} tin ham + {so_sv[1]} tin spam = {so_sv.sum()} / {X_hoc.shape[0]} tin học ({so_sv.sum() / X_hoc.shape[0]:.0%})\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"**Kết quả:** `SVM (tuyến tính): accuracy 0.982 | precision 0.979 | recall 0.877 | F1 0.926 | chặn nhầm 3 | bỏ lọt 20` · `Support vectors: 657 tin ham + 291 tin spam = 948 / 3878 tin học (24%)`\n",
"\n",
"📦 **`svm`** = SVM tuyến tính đã học.\n",
"\n",
"👉 **Hiểu:** so với Perceptron, SVM **chặn nhầm ít hơn hẳn** (3 so với 14) — vùng đệm rộng giúp tin thật \"hơi giống rác\" vẫn nằm đúng phía. Chỉ **24%** số tin (những tin khó, nằm sát ranh giới) thật sự quyết định ranh giới; 76% còn lại có bỏ đi thì ranh giới không đổi."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## Phần 5 — Kernel trick: khi một đường thẳng không đủ\n",
"\n",
"📍 **Vẫn là ứng viên 2 (SVM), câu hỏi phụ:** Perceptron và SVM đều vẽ ranh giới **thẳng**. Nếu dữ liệu không tách được bằng đường thẳng thì sao?\n",
"\n",
"Nếu tin ham và spam đan xen đến mức **không đường thẳng nào** tách được, SVM dùng **kernel**: tính toán như thể dữ liệu được \"nhấc\" lên một không gian nhiều chiều hơn, nơi một mặt phẳng tách được — nhìn lại ở không gian cũ, ranh giới thành **đường cong**. Kernel phổ biến nhất là **RBF**.\n",
"\n",
"Để **nhìn thấy** được, ta chỉ dùng 2 đặc trưng đơn giản của mỗi tin: **độ dài** (số ký tự) và **số chữ số** (tin rác hay có số điện thoại, mã, giá tiền). Mỗi chấm là 1 tin thật.\n",
"\n",
"**Cách đọc hình:** trục ngang = log(1 + độ dài), trục đứng = log(1 + số chữ số); chấm xanh = ham, chấm đỏ = spam; **nền màu** = vùng mà mô hình sẽ đoán là spam (đỏ nhạt) hay ham (xanh nhạt); đường biên giữa 2 màu nền = ranh giới."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# 2 đặc trưng đơn giản, nén bằng log1p cho dễ nhìn (số rất lớn bị co lại)\n",
"hai_dac_trung = np.log1p(pd.DataFrame({\"do_dai\": tin.noi_dung.str.len(), \"so_chu_so\": tin.noi_dung.str.count(r\"\\d\")}))\n",
"A_hoc, A_thi = hai_dac_trung.loc[vi_tri_hoc], hai_dac_trung.loc[vi_tri_thi]\n",
"# Lưới điểm phủ cả hình, để tô màu vùng đoán\n",
"xx, yy = np.meshgrid(np.linspace(0, 6.5, 300), np.linspace(0, 4.2, 300))\n",
"luoi = pd.DataFrame({\"do_dai\": xx.ravel(), \"so_chu_so\": yy.ravel()})\n",
"hinh, o = plt.subplots(1, 2, figsize=(14, 5))\n",
"for truc, kernel in zip(o, [\"linear\", \"rbf\"]):\n",
" m = SVC(kernel=kernel, C=1, gamma=\"scale\").fit(A_hoc, y_hoc)\n",
" truc.contourf(xx, yy, m.predict(luoi).reshape(xx.shape), alpha=0.25, cmap=\"coolwarm\")\n",
" mau = np.where(y_thi == 1, \"red\", \"blue\")\n",
" truc.scatter(A_thi.do_dai, A_thi.so_chu_so, c=mau, s=6, alpha=0.5)\n",
" p = m.predict(A_thi)\n",
" truc.set_title(f\"kernel='{kernel}': đúng {accuracy_score(y_thi, p):.1%}, recall {recall_score(y_thi, p):.1%}\")\n",
" truc.set_xlabel(\"log(1 + độ dài tin)\"); truc.set_ylabel(\"log(1 + số chữ số)\")\n",
"plt.tight_layout(); plt.show()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"**Kết quả:** 2 hình cạnh nhau. Trái `kernel='linear': đúng 97.6%, recall 85.9%` — ranh giới là **đường thẳng** chéo. Phải `kernel='rbf': đúng 97.4%, recall 81.6%` — ranh giới **cong**, võng xuống quanh độ dài log ≈ 5.\n",
"\n",
"👉 **Hiểu:**\n",
"- Chấm đỏ (rác) dồn thành một cột ở log(độ dài) ≈ 5, tức **khoảng 150 ký tự**: tin rác thường **viết kín một tin SMS** (giới hạn 160 ký tự) và **nhiều chữ số** (số điện thoại, mã, giá).\n",
"- Kernel RBF **uốn cong** ranh giới được — đúng ý tưởng \"nhấc dữ liệu lên không gian nhiều chiều\". Nhưng ở đây nó **không đúng hơn** đường thẳng (97,4% so với 97,6%).\n",
"- Với văn bản (7.395 chiều — mỗi từ một chiều), dữ liệu **đã gần như tách được bằng đường thẳng**, nên SVM tuyến tính là lựa chọn chuẩn cho lọc văn bản. Kernel phát huy khi có **ít** đặc trưng mà dữ liệu đan xen phức tạp. Không có thuật toán \"luôn tốt hơn\" — phải **thử và đo**."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## Phần 6 — Logistic Regression: từ \"Có / Không\" sang **xác suất**\n",
"\n",
"📍 **Bước 4 — Coding, ứng viên 3/5.** SVM chặn nhầm ít (3 tin) nhưng chỉ trả lời Có / Không.\n",
"\n",
"Perceptron và SVM chỉ nói **spam / ham**. Ở SOC ta muốn biết **chắc bao nhiêu phần trăm** để xử lý khác nhau. Logistic Regression cũng tính một **điểm** như Perceptron, rồi đưa điểm qua **hàm sigmoid** để thành **xác suất** từ 0 đến 1:\n",
"\n",
"**xác suất = 1 / (1 + e^(−điểm))**\n",
"\n",
"### 6.1 Hàm sigmoid trông thế nào?"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"diem_vd = np.array([-6, -3, -1, 0, 1, 3, 6])\n",
"print(pd.DataFrame({\"điểm\": diem_vd, \"xác suất spam = 1/(1+e^-điểm)\": (1 / (1 + np.exp(-diem_vd))).round(3)}).to_string(index=False))\n",
"z = np.linspace(-8, 8, 200)\n",
"plt.figure(figsize=(7, 3)); plt.plot(z, 1 / (1 + np.exp(-z))); plt.axhline(0.5, ls=\"--\", c=\"gray\")\n",
"plt.xlabel(\"điểm\"); plt.ylabel(\"xác suất spam\"); plt.title(\"Sigmoid: điểm âm -> gần 0, điểm 0 -> 50%, điểm dương -> gần 1\"); plt.show()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"**Kết quả:** bảng `-6 → 0.002`, `-3 → 0.047`, `-1 → 0.269`, `0 → 0.500`, `1 → 0.731`, `3 → 0.953`, `6 → 0.998` và đường cong chữ S.\n",
"\n",
"👉 **Hiểu:** điểm âm lớn → gần **0%**, điểm 0 → **50%** (không chắc), điểm dương lớn → gần **100%**. Sigmoid \"bóp\" mọi điểm vào khoảng 0–1 để đọc như **phần trăm**."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 6.2 Huấn luyện — và vấn đề \"lớp hiếm\"\n",
"\n",
"Chỉ ~13% tin là spam. Với mặc định, mô hình \"ngại\" đoán spam. `class_weight=\"balanced\"` = cho mỗi tin spam **nặng ký hơn** khi học (theo tỉ lệ ngược số lượng), để mô hình không bỏ qua lớp hiếm."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from sklearn.linear_model import LogisticRegression\n",
"lr_mac_dinh = LogisticRegression(max_iter=1000).fit(X_hoc, y_hoc)\n",
"logistic = LogisticRegression(max_iter=1000, class_weight=\"balanced\").fit(X_hoc, y_hoc)\n",
"# 📦 logistic = Logistic Regression có cân bằng lớp (dùng ở các phần sau)\n",
"print(pd.DataFrame([do_luong(\"Logistic mặc định\", lr_mac_dinh.predict(X_thi)),\n",
" do_luong(\"Logistic class_weight='balanced'\", logistic.predict(X_thi))]).round(3).to_string(index=False))"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"**Kết quả:**\n",
"```\n",
" mô hình accuracy precision recall F1 chặn nhầm (FP) bỏ lọt (FN)\n",
" Logistic mặc định 0.963 0.967 0.730 0.832 4 44\n",
"Logistic class_weight='balanced' 0.978 0.904 0.926 0.915 16 12\n",
"```\n",
"📦 **`logistic`** = Logistic Regression có cân bằng lớp (dùng ở các phần sau).\n",
"\n",
"👉 **Hiểu:** mặc định, mô hình **bỏ lọt 44 tin rác** vì tin rác hiếm (13%). Cân bằng lớp → bỏ lọt còn **12**, đổi lại chặn nhầm tăng từ 4 lên 16. Một tham số, đổi hẳn cách mô hình cư xử với lớp hiếm."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 6.3 Phân luồng 3 làn theo xác suất"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"xac_suat = logistic.predict_proba(X_thi)[:, 1] # cột [:, 1] = xác suất là spam của từng tin thi\n",
"# ✏️ SỬA: 2 ngưỡng phân luồng\n",
"NGUONG_CACH_LY, NGUONG_CHAN = 0.5, 0.9\n",
"lan = np.where(xac_suat >= NGUONG_CHAN, \"3. CHẶN\", np.where(xac_suat >= NGUONG_CACH_LY, \"2. CÁCH LY (người xem)\", \"1. CHO QUA\"))\n",
"bang_lan = pd.DataFrame({\"làn\": lan, \"là spam thật\": y_thi.to_numpy()}).groupby(\"làn\")[\"là spam thật\"].agg([\"size\", \"sum\"])\n",
"bang_lan.columns = [\"số tin\", \"trong đó spam thật\"]\n",
"# 📦 xac_suat = xác suất spam của từng tin thi · lan = làn được xếp\n",
"print(bang_lan.to_string())\n",
"print(\"\\nVí dụ 3 tin ở làn CÁCH LY:\")\n",
"for nd, p_ in list(zip(tin.noi_dung[vi_tri_thi][lan == \"2. CÁCH LY (người xem)\"], xac_suat[lan == \"2. CÁCH LY (người xem)\"]))[:3]:\n",
" print(f\" {p_:.0%} | {nd}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"**Kết quả:**\n",
"```\n",
" số tin trong đó spam thật\n",
"1. CHO QUA 1126 12\n",
"2. CÁCH LY (người xem) 75 60\n",
"3. CHẶN 92 91\n",
"```\n",
"rồi 3 tin ở làn cách ly: `69% | RCT' THNQ Adrian for U text. Rgds Vatian`, `67% | I am waiting for your call sir.`, `68% | Your daily text from me – a favour this time`.\n",
"\n",
"📦 **`xac_suat`** = xác suất spam của 1.293 tin thi · **`lan`** = làn được xếp.\n",
"\n",
"👉 **Hiểu:** làn **CHẶN** gần như toàn tin rác (91/92) → chặn tự động an toàn. Làn **CÁCH LY** chỉ 75 tin — đội SOC chỉ phải xem **6%** số tin, trong đó 60 tin đúng là rác. Làn **CHO QUA** còn lọt 12 tin rác. Đổi 2 ngưỡng (✏️) để xem làn nào phình ra, làn nào co lại."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## Phần 7 — Naive Bayes: thám tử thống kê\n",
"\n",
"📍 **Bước 4 — Coding, ứng viên 4/5**, một cách nghĩ **khác hẳn**: 3 ứng viên trước đều **vẽ ranh giới** (tổng có trọng số); Naive Bayes thì **đếm xác suất** từ quá khứ.\n",
"\n",
"Naive Bayes hỏi: *\"Trong quá khứ, tin chứa từ này là spam bao nhiêu phần?\"* rồi **nhân** bằng chứng của từng từ lại với nhau. \"**Ngây thơ**\" vì nó coi các từ **độc lập** với nhau (không hiểu \"not free\" khác \"free\"). Nhờ ngây thơ mà tính rất nhanh.\n",
"\n",
"### 7.1 Tự tính \"bằng chứng\" của một từ từ dữ liệu học"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ✏️ SỬA: từ muốn soi (viết thường), ví dụ \"prize\", \"call\", \"home\", \"love\"\n",
"TU = \"free\"\n",
"co_tu = tin.noi_dung[vi_tri_hoc].str.lower().str.contains(rf\"\\b{TU}\\b\") # \\b = ranh giới từ (để \"free\" không khớp \"freedom\")\n",
"so_spam, so_ham = int((co_tu & (y_hoc == 1)).sum()), int((co_tu & (y_hoc == 0)).sum())\n",
"print(f\"Trong phần học có {co_tu.sum()} tin chứa '{TU}': {so_spam} spam, {so_ham} ham\")\n",
"print(f\"-> nếu chỉ dựa vào từ này: xác suất spam = {so_spam} / {so_spam + so_ham} = {so_spam / max(so_spam + so_ham, 1):.0%}\")\n",
"print(f\"(trong khi tỉ lệ spam chung chỉ {y_hoc.mean():.0%})\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"**Kết quả:** `Trong phần học có 148 tin chứa 'free': 112 spam, 36 ham` · `-> nếu chỉ dựa vào từ này: xác suất spam = 112 / 148 = 76%` · `(trong khi tỉ lệ spam chung chỉ 13%)`\n",
"\n",
"👉 **Hiểu:** đó chính là \"kinh nghiệm quá khứ\" của Naive Bayes: thấy `free` thì khả năng rác nhảy từ **13% lên 76%**. Naive Bayes làm việc này cho **mọi từ** trong tin rồi nhân các bằng chứng lại. Thử `\"prize\"`, `\"home\"`, `\"love\"`."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 7.2 Huấn luyện, từ \"tố cáo\" mạnh nhất, và tốc độ"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from sklearn.naive_bayes import MultinomialNB\n",
"naive_bayes = MultinomialNB().fit(X_hoc, y_hoc)\n",
"print(pd.DataFrame([do_luong(\"Naive Bayes\", naive_bayes.predict(X_thi))]).round(3).to_string(index=False))\n",
"# Từ \"tố cáo\" mạnh nhất = từ có (xác suất xuất hiện trong spam) / (trong ham) lớn nhất -> hiệu 2 log xác suất\n",
"ti_so = naive_bayes.feature_log_prob_[1] - naive_bayes.feature_log_prob_[0]\n",
"print(\"\\n12 từ nghiêng về SPAM nhất:\", list(tu_dien[np.argsort(ti_so)[-12:][::-1]]))\n",
"print(\"8 từ nghiêng về HAM nhất :\", list(tu_dien[np.argsort(ti_so)[:8]]))\n",
"\n",
"# Tốc độ học: lặp 5 lần lấy thời gian nhỏ nhất cho công bằng\n",
"def thoi_gian_hoc(mo_hinh):\n",
" return min((lambda t0: (mo_hinh.fit(X_hoc, y_hoc), time.perf_counter() - t0)[1])(time.perf_counter()) for _ in range(5))\n",
"print(\"\\nThời gian học trên\", X_hoc.shape[0], \"tin:\")\n",
"for ten, m in [(\"Naive Bayes\", MultinomialNB()), (\"Perceptron\", Perceptron(random_state=RANDOM_STATE)),\n",
" (\"SVM\", LinearSVC(random_state=RANDOM_STATE)), (\"Logistic\", LogisticRegression(max_iter=1000, class_weight=\"balanced\"))]:\n",
" print(f\" {ten:12s} {thoi_gian_hoc(m) * 1000:6.1f} ms\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"**Kết quả:** `Naive Bayes: accuracy 0.948 | precision 1.0 | recall 0.589 | F1 0.741 | chặn nhầm 0 | bỏ lọt 67` · 12 từ nghiêng về spam nhất: `prize, claim, www, nokia, 150p, uk, guaranteed, co, 18, tone, 500, 1000` · nghiêng về ham: `gt, lt, my, lor, ok, but, da, he` · thời gian học: `Naive Bayes 2.6 ms`, `Perceptron 3.2 ms`, `SVM 7.9 ms`, `Logistic 12.4 ms`\n",
"\n",
"📦 **`naive_bayes`** = mô hình đã học.\n",
"\n",
"👉 **Hiểu:** Naive Bayes **cực kỳ thận trọng**: không chặn nhầm tin nào (precision 1,0) nhưng **bỏ lọt 67/163 tin rác**. Nó học **nhanh nhất** (2,6 ms). Các từ \"tố cáo\" rất đúng chất tin rác Anh quốc: giải thưởng, `150p` (giá tin nhắn), `18` (18+), `nokia`, `tone` (nhạc chuông)."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## Phần 8 — Hội đồng (Voting): khi các chuyên gia bắt tay nhau\n",
"\n",
"📍 **Bước 4 — Coding, ứng viên 5/5.** Đã có 4 mô hình, mỗi cái mạnh / yếu một kiểu (Naive Bayes không chặn nhầm nhưng bỏ lọt nhiều; Logistic bắt nhiều nhưng chặn nhầm hơn) → cho chúng **cùng bỏ phiếu**.\n",
"\n",
"Mỗi mô hình có \"điểm mù\" riêng. **Hội đồng** cho nhiều mô hình cùng quyết định:\n",
"- **hard voting**: mỗi mô hình bỏ 1 phiếu spam / ham, theo số đông;\n",
"- **soft voting**: lấy **trung bình xác suất** của các mô hình;\n",
"- **có trọng số**: phiếu của mô hình đáng tin hơn được nhân lên (ví dụ ×2).\n",
"\n",
"SVM không tự cho xác suất, nên ta bọc nó bằng `CalibratedClassifierCV` — một bộ \"đổi điểm của SVM thành xác suất\" học từ dữ liệu.\n",
"\n",
"### 8.1 Các mô hình có sai cùng chỗ không?"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"cac_mo_hinh = {\"Perceptron\": perceptron, \"SVM\": svm, \"Logistic\": logistic, \"Naive Bayes\": naive_bayes}\n",
"phieu = pd.DataFrame({ten: m.predict(X_thi) for ten, m in cac_mo_hinh.items()}) # mỗi cột = phiếu của 1 mô hình\n",
"sai = phieu.ne(y_thi.to_numpy(), axis=0) # True ở chỗ mô hình đó đoán sai\n",
"print(\"Số tin mỗi mô hình đoán SAI:\", sai.sum().to_dict())\n",
"print(\"Số tin mà các mô hình KHÔNG thống nhất:\", int((phieu.nunique(axis=1) > 1).sum()), \"/\", len(phieu))\n",
"print(\"Số tin mà CẢ 4 cùng sai:\", int(sai.all(axis=1).sum()))"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"**Kết quả:** `Số tin mỗi mô hình đoán SAI: {'Perceptron': 31, 'SVM': 23, 'Logistic': 28, 'Naive Bayes': 67}` · `Số tin mà các mô hình KHÔNG thống nhất: 81 / 1293` · `Số tin mà CẢ 4 cùng sai: 11`\n",
"\n",
"📦 **`phieu`** = bảng phiếu của 4 mô hình cho 1.293 tin · **`sai`** = True ở chỗ mô hình đoán sai.\n",
"\n",
"👉 **Hiểu:** các mô hình **sai ở những chỗ khác nhau** (81 tin bất đồng) → hội đồng có cơ hội sửa lỗi cho nhau. Nhưng **11 tin cả 4 cùng sai** — không cách bỏ phiếu nào cứu được; hội đồng **không** xóa sạch mọi điểm mù."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 8.2 So sánh các kiểu hội đồng"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from sklearn.ensemble import VotingClassifier\n",
"from sklearn.calibration import CalibratedClassifierCV\n",
"# ✏️ SỬA: trọng số phiếu [SVM, Logistic, Naive Bayes]. Thử [1, 1, 1], [1, 2, 1], [3, 1, 1]\n",
"TRONG_SO = [2, 1, 1]\n",
"def hoi_dong(kieu, trong_so=None):\n",
" thanh_vien = [(\"svm\", CalibratedClassifierCV(LinearSVC(random_state=RANDOM_STATE), cv=5) if kieu == \"soft\" else LinearSVC(random_state=RANDOM_STATE)),\n",
" (\"logistic\", LogisticRegression(max_iter=1000, class_weight=\"balanced\")),\n",
" (\"naive_bayes\", MultinomialNB())]\n",
" return VotingClassifier(thanh_vien, voting=kieu, weights=trong_so).fit(X_hoc, y_hoc)\n",
"\n",
"hd_hard, hd_soft, hd_trong_so = hoi_dong(\"hard\"), hoi_dong(\"soft\"), hoi_dong(\"soft\", TRONG_SO)\n",
"# 📦 hd_trong_so = hội đồng soft voting có trọng số (dùng ở Phần 9, 10)\n",
"bang_kq = pd.DataFrame([do_luong(ten, m.predict(X_thi)) for ten, m in\n",
" [(\"SVM (một mình)\", svm), (\"Hội đồng hard\", hd_hard), (\"Hội đồng soft, phiếu đều\", hd_soft),\n",
" (f\"Hội đồng soft, trọng số {TRONG_SO}\", hd_trong_so)]])\n",
"bang_kq.round(3).set_index(\"mô hình\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"**Kết quả:**\n",
"```\n",
" accuracy precision recall F1 chặn nhầm (FP) bỏ lọt (FN)\n",
"SVM (một mình) 0.982 0.979 0.877 0.926 3 20\n",
"Hội đồng hard 0.982 0.979 0.877 0.926 3 20\n",
"Hội đồng soft, phiếu đều 0.981 0.973 0.877 0.923 4 20\n",
"Hội đồng soft, trọng số [2, 1, 1] 0.984 0.973 0.896 0.933 4 17\n",
"```\n",
"📦 **`hd_trong_so`** = hội đồng soft voting có trọng số (Phần 9, 10 dùng lại) · **`hoi_dong(kieu, trong_so)`** = hàm dựng một hội đồng.\n",
"\n",
"👉 **Hiểu:** hội đồng **chia phiếu đều không hơn** SVM đứng một mình (Naive Bayes \"kéo\" kết quả xuống). Cho SVM — chuyên gia giỏi nhất — **phiếu ×2** thì hội đồng **vượt** mọi thành viên: F1 0,933, bỏ lọt còn 17. Bài học: hội đồng **không tự động** giỏi hơn; cần **chọn trọng số bằng số liệu**. Thử `[1, 1, 1]`, `[3, 1, 1]`."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## Phần 9 — Đánh giá: tin con số, không tin cảm giác\n",
"\n",
"📍 **Bước 6 — Đánh giá các mô hình:** đặt cả 5 ứng viên lên cùng một bảng, cạnh mốc lười và luật cứng, rồi chọn mô hình và ngưỡng.\n",
"\n",
"### 9.1 Bảng tổng hợp mọi cách — kể cả \"lười\" và luật cứng"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"tong_hop = pd.DataFrame([\n",
" do_luong(\"Đoán mọi tin là ham ('lười')\", np.zeros(len(y_thi), dtype=int)),\n",
" do_luong(f\"Luật {len(TU_KHOA)} từ khóa (Phần 1)\", bi_chan[vi_tri_thi].to_numpy()),\n",
" do_luong(\"Perceptron\", perceptron.predict(X_thi)),\n",
" do_luong(\"SVM\", svm.predict(X_thi)),\n",
" do_luong(\"Logistic (balanced)\", logistic.predict(X_thi)),\n",
" do_luong(\"Naive Bayes\", naive_bayes.predict(X_thi)),\n",
" do_luong(f\"Hội đồng trọng số {TRONG_SO}\", hd_trong_so.predict(X_thi)),\n",
"])\n",
"tong_hop.round(3).set_index(\"mô hình\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"**Kết quả:**\n",
"```\n",
" accuracy precision recall F1 chặn nhầm (FP) bỏ lọt (FN)\n",
"Đoán mọi tin là ham ('lười') 0.874 0.000 0.000 0.000 0 163\n",
"Luật 10 từ khóa (Phần 1) 0.919 0.693 0.638 0.665 46 59\n",
"Perceptron 0.976 0.912 0.896 0.904 14 17\n",
"SVM 0.982 0.979 0.877 0.926 3 20\n",
"Logistic (balanced) 0.978 0.904 0.926 0.915 16 12\n",
"Naive Bayes 0.948 1.000 0.589 0.741 0 67\n",
"Hội đồng trọng số [2, 1, 1] 0.984 0.973 0.896 0.933 4 17\n",
"```\n",
"👉 **Hiểu:**\n",
"- Mô hình \"lười\" có accuracy **87,4%** mà F1 = **0** — vì vậy không bao giờ báo cáo chỉ bằng accuracy.\n",
"- **Không mô hình nào thắng mọi cột**: Naive Bayes chặn nhầm ít nhất (0), Logistic bỏ lọt ít nhất (12), hội đồng có F1 cao nhất. Chọn mô hình = chọn **loại lỗi** mà tổ chức chấp nhận được."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 9.2 Ma trận nhầm lẫn của hội đồng\n",
"\n",
"**Cách đọc:** hàng = **sự thật** (ham, spam), cột = **máy đoán**. Góc trên trái = ham cho qua đúng (TN); trên phải = **chặn nhầm** (FP); dưới trái = **bỏ lọt** (FN); dưới phải = bắt đúng (TP)."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from sklearn.metrics import ConfusionMatrixDisplay\n",
"mt = confusion_matrix(y_thi, hd_trong_so.predict(X_thi))\n",
"ConfusionMatrixDisplay(mt, display_labels=[\"ham\", \"spam\"]).plot(cmap=\"Blues\")\n",
"plt.title(\"Hội đồng có trọng số — hàng: sự thật, cột: máy đoán\"); plt.show()\n",
"tn, fp, fn, tp = mt.ravel()\n",
"print(f\"Cho qua đúng (TN) {tn} | CHẶN NHẦM (FP) {fp} | BỎ LỌT (FN) {fn} | bắt đúng (TP) {tp}\")\n",
"print(f\"precision = TP / (TP + FP) = {tp} / {tp + fp} = {tp / (tp + fp):.3f}\")\n",
"print(f\"recall = TP / (TP + FN) = {tp} / {tp + fn} = {tp / (tp + fn):.3f}\")\n",
"print(\"\\n3 tin RÁC bị bỏ lọt:\")\n",
"du_doan_hd = hd_trong_so.predict(X_thi)\n",
"print(tin.noi_dung[vi_tri_thi][(y_thi == 1).to_numpy() & (du_doan_hd == 0)].head(3).to_string())\n",
"print(\"\\nTin THẬT bị chặn nhầm:\")\n",
"print(tin.noi_dung[vi_tri_thi][(y_thi == 0).to_numpy() & (du_doan_hd == 1)].head(4).to_string())"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"**Kết quả:** hình ma trận 2×2 rồi `Cho qua đúng (TN) 1126 | CHẶN NHẦM (FP) 4 | BỎ LỌT (FN) 17 | bắt đúng (TP) 146` · `precision = 146 / 150 = 0.973` · `recall = 146 / 163 = 0.896`. Tin rác bị bỏ lọt (ví dụ quảng cáo `Burger King - Wanna play footy...`, `Missed call alert. These numbers called but left no message. 07008009200`); tin thật bị chặn nhầm: `Glad to see your reply.`, `Are you free now?can i call now?`, `Nokia phone is lovly..`.\n",
"\n",
"👉 **Hiểu:** đọc lỗi mới thấy **vì sao** máy sai: tin thật chứa đúng những từ \"nghi vấn\" (`free`, `call`, `reply`, `nokia`); tin rác bị lọt thì viết như tin bình thường (quảng cáo, \"cuộc gọi nhỡ\"). Đây là chỗ cần tín hiệu khác ngoài chữ (người gửi, link)."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 9.3 Ngưỡng: đổi chặn nhầm lấy bỏ lọt"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ✏️ SỬA: các ngưỡng muốn thử (trên xác suất của Logistic ở 6.3)\n",
"CAC_NGUONG = [0.9, 0.7, 0.5, 0.3, 0.1]\n",
"dong = []\n",
"for ng in CAC_NGUONG:\n",
" p = (xac_suat >= ng).astype(int)\n",
" tn_, fp_, fn_, tp_ = confusion_matrix(y_thi, p).ravel()\n",
" dong.append({\"ngưỡng\": ng, \"precision\": tp_ / max(tp_ + fp_, 1), \"recall\": tp_ / (tp_ + fn_), \"chặn nhầm (FP)\": fp_, \"bỏ lọt (FN)\": fn_})\n",
"bang_nguong = pd.DataFrame(dong).set_index(\"ngưỡng\")\n",
"bang_nguong[[\"precision\", \"recall\"]].plot(marker=\"o\", figsize=(7, 3), title=\"Hạ ngưỡng: recall tăng, precision giảm\")\n",
"plt.gca().invert_xaxis(); plt.show()\n",
"bang_nguong.round(3)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"**Kết quả:** biểu đồ 2 đường và bảng:\n",
"\n",
"| ngưỡng | precision | recall | chặn nhầm | bỏ lọt |\n",
"|---|---|---|---|---|\n",
"| 0.9 | 0.989 | 0.558 | 1 | 72 |\n",
"| 0.7 | 0.971 | 0.828 | 4 | 28 |\n",
"| 0.5 | 0.904 | 0.926 | 16 | 12 |\n",
"| 0.3 | 0.668 | 0.951 | 77 | 8 |\n",
"| 0.1 | 0.221 | 1.000 | 576 | 0 |\n",
"\n",
"📦 **`bang_nguong`** = bảng trên.\n",
"\n",
"👉 **Hiểu:** **không có ngưỡng nào vừa 0 chặn nhầm vừa 0 bỏ lọt**. Hạ từ 0,5 xuống 0,3: bắt thêm 4 tin rác nhưng chặn nhầm thêm 61 tin thật. Hạ xuống 0,1: bắt **hết** tin rác nhưng chặn nhầm **576** tin thật — hệ thống sẽ bị tắt ngay ngày đầu. Ngưỡng là **quyết định nghiệp vụ**: thiệt hại của 1 lần bỏ lọt so với 1 lần chặn nhầm."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## Phần 10 — AI đối đầu AI: kẻ tấn công chèn \"từ an toàn\"\n",
"\n",
"📍 **Bước 6 — Đánh giá các mô hình (thử tấn công):** mô hình tốt trên đề thi chưa chắc tốt khi kẻ tấn công **cố tình** lừa nó.\n",
"\n",
"Kẻ tấn công biết bộ lọc chấm điểm theo **từ**. Chiêu đơn giản: giữ nguyên nội dung rác, **chèn thêm một đoạn từ ngữ \"vô hại\"** (giống tin nhắn bạn bè) để kéo điểm về phía ham. Ta thử chiêu này lên **mọi tin rác** trong đề thi.\n",
"\n",
"### 10.1 Tấn công"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ✏️ SỬA: đoạn \"từ an toàn\" kẻ tấn công chèn vào cuối tin rác\n",
"CAU_CHEN = \" ok see you later at home lor going to sleep now love you dear\"\n",
"tin_rac_thi = tin.noi_dung[vi_tri_thi][(y_thi == 1).to_numpy()]\n",
"X_goc, X_bi_chen = tfidf.transform(tin_rac_thi), tfidf.transform(tin_rac_thi + CAU_CHEN)\n",
"dong = []\n",
"for ten, m in list(cac_mo_hinh.items()) + [(f\"Hội đồng trọng số {TRONG_SO}\", hd_trong_so)]:\n",
" dong.append({\"mô hình\": ten, \"bắt được TRƯỚC\": m.predict(X_goc).mean(), \"bắt được SAU khi chèn\": m.predict(X_bi_chen).mean()})\n",
"tan_cong = pd.DataFrame(dong).set_index(\"mô hình\")\n",
"tan_cong.plot(kind=\"barh\", figsize=(8, 3.5), title=f\"Tỉ lệ tin rác bị bắt ({len(tin_rac_thi)} tin rác)\")\n",
"plt.xlabel(\"tỉ lệ bắt được\"); plt.show()\n",
"print(\"Ví dụ tin rác sau khi bị chèn:\", repr(tin_rac_thi.iloc[0] + CAU_CHEN))\n",
"tan_cong.round(3)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"**Kết quả:** biểu đồ thanh và bảng (tỉ lệ trong 163 tin rác của đề thi bị bắt):\n",
"\n",
"| mô hình | TRƯỚC khi chèn | SAU khi chèn |\n",
"|---|---|---|\n",
"| Perceptron | 0.896 | 0.503 |\n",
"| SVM | 0.877 | 0.675 |\n",
"| Logistic | 0.926 | 0.712 |\n",
"| Naive Bayes | 0.589 | **0.031** |\n",
"| Hội đồng trọng số [2, 1, 1] | 0.896 | 0.656 |\n",
"\n",
"📦 **`CAU_CHEN`** = câu kẻ tấn công chèn (sửa được) · **`tin_rac_thi`** = 163 tin rác của đề thi · **`tan_cong`** = bảng trên.\n",
"\n",
"👉 **Hiểu:** chỉ chèn một câu \"tán gẫu\" vô hại, **mọi** mô hình đều tụt. **Naive Bayes sụp gần hết** (59% → 3%): nó cộng bằng chứng từng từ độc lập, nên cả chục từ \"ham\" kéo át vài từ \"rác\" — đúng điểm yếu \"ngây thơ\". SVM và Logistic chịu đòn tốt hơn nhưng vẫn mất 20 điểm phần trăm."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 10.2 Phòng thủ: dạy lại máy bằng chính chiêu của kẻ tấn công\n",
"\n",
"**Huấn luyện đối kháng** (adversarial training): tạo thêm các tin rác **đã bị chèn từ** ngay trong phần học, gắn đúng nhãn spam, rồi học lại. Máy học được rằng \"từ an toàn\" xuất hiện cạnh nội dung rác **không** làm tin đó sạch.\n",
"\n",
"Để thử công bằng, phần học dùng một câu chèn **khác** câu ở 10.1 (kẻ tấn công thật sẽ không dùng đúng câu ta đoán trước)."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ✏️ SỬA: câu chèn mà PHÒNG THỦ đoán trước để tập (khác câu kẻ tấn công dùng ở 10.1)\n",
"CAU_TAP = \" hey dear how was your day see you at dinner tonight ok\"\n",
"tin_rac_hoc = tin.noi_dung[vi_tri_hoc][(y_hoc == 1).to_numpy()]\n",
"noi_dung_moi = pd.concat([tin.noi_dung[vi_tri_hoc], tin_rac_hoc + CAU_TAP]) # thêm bản tin rác đã chèn\n",
"nhan_moi = pd.concat([y_hoc, pd.Series(1, index=tin_rac_hoc.index)]) # tất cả bản thêm đều là spam\n",
"tfidf_dk = TfidfVectorizer()\n",
"X_hoc_dk = tfidf_dk.fit_transform(noi_dung_moi)\n",
"dong = []\n",
"for ten, m in [(\"Naive Bayes\", MultinomialNB()), (\"SVM\", LinearSVC(random_state=RANDOM_STATE)),\n",
" (\"Logistic\", LogisticRegression(max_iter=1000, class_weight=\"balanced\"))]:\n",
" m.fit(X_hoc_dk, nhan_moi)\n",
" p_thi = m.predict(tfidf_dk.transform(tin.noi_dung[vi_tri_thi]))\n",
" dong.append({\"mô hình (học lại)\": ten,\n",
" \"bắt tin rác bị chèn (câu 10.1)\": m.predict(tfidf_dk.transform(tin_rac_thi + CAU_CHEN)).mean(),\n",
" \"F1 trên đề thi thường\": f1_score(y_thi, p_thi), \"chặn nhầm (FP)\": int(((p_thi == 1) & (y_thi == 0).to_numpy()).sum())})\n",
"pd.DataFrame(dong).round(3).set_index(\"mô hình (học lại)\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"**Kết quả:**\n",
"\n",
"| mô hình (học lại) | bắt tin rác bị chèn (câu 10.1) | F1 trên đề thi thường | chặn nhầm |\n",
"|---|---|---|---|\n",
"| Naive Bayes | 0.460 (trước: 0.031) | 0.861 | 5 |\n",
"| SVM | 0.804 (trước: 0.675) | 0.922 | 3 |\n",
"| Logistic | 0.834 (trước: 0.712) | 0.901 | 14 |\n",
"\n",
"📦 **`noi_dung_moi`, `nhan_moi`** = phần học + các tin rác đã bị chèn câu tập · **`tfidf_dk`** = từ điển học lại.\n",
"\n",
"👉 **Hiểu:** chỉ cần cho máy **xem trước** một chiêu chèn (dù khác câu kẻ tấn công thật dùng), cả 3 mô hình đều bắt lại được nhiều hơn hẳn — Naive Bayes từ 3% lên 46%, Logistic lên 83% — mà kết quả trên tin thường **gần như không đổi**. Đó là **huấn luyện đối kháng**: phòng thủ phải được tập lại liên tục theo chiêu của kẻ tấn công."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## Phần 11 — Tổng kết\n",
"\n",
"📍 **Bước 7 — Quy tắc bàn tay phải:** tổng kết, báo cáo.\n",
"\n",
"### Bài này đã đo được gì?\n",
"\n",
"Các con số chính, theo đúng thứ tự pipeline:\n",
"- **Naïve Bayes** (7.2): không chặn nhầm tin nào (precision 1,0) nhưng bỏ lọt 67/163 tin rác; bị chèn \"từ an toàn\" (10.1) thì chỉ còn bắt 3% → nhanh, gọn nhưng **dễ bị lách nhất**.\n",
"- **Ngưỡng** (9.3): hạ từ 0,5 xuống 0,3 → bắt thêm 4 tin rác nhưng chặn nhầm thêm 61 tin thật. Cùng một mô hình, ngưỡng mới quyết định đánh đổi.\n",
"- **Hội đồng** (8.2): chia phiếu đều không hơn SVM đứng một mình; cho SVM phiếu ×2 thì vượt mọi thành viên (F1 0,933).\n",
"\n",
"### 11.0 Quy tắc bàn tay ✋ áp vào bài này\n",
"\n",
"Xòe bàn tay, đếm từ ngón cái: 5 bước **không được bỏ** của mọi bài toán phát hiện tấn công bằng AI (đầy đủ ở Lab 3 Phần 11). Bài này đã làm đủ 5 bước:\n",
"\n",
"| Ngón | Bước | Câu hỏi phải tự trả lời | Trong bài này | Quên bước này thì… |\n",
"|---|---|---|---|---|\n",
"| 👍 **1. Ngón cái** | **DỮ LIỆU sạch** | Có bản sao, có lộ đề không? | bỏ 403 bản sao trước khi chia (0.2) | 161 tin đề thi đã nằm sẵn trong phần học → điểm cao **ảo** |\n",
"| ☝️ **2. Ngón trỏ** | **ĐẶC TRƯNG đúng** | Biến chữ thành số thế nào? Học từ điển ở đâu? | TF-IDF, từ điển **chỉ** học từ phần học (2.2) | lấy cả đề thi để học từ điển = lộ đề |\n",
"| ✋ **3. Ngón giữa** | **CHỌN CÔNG CỤ** | Có nhãn không? Dữ liệu dạng gì? | văn bản có nhãn → 5 ứng viên có giám sát (Phần 3 – 8); bảng tra ở *Trước khi bắt đầu* | dùng luật từ khóa: chặn nhầm 189, bỏ lọt 199 (1.1) |\n",
"| 💍 **4. Ngón áp út** | **ĐO trung thực** | Hơn mốc lười bao nhiêu? Chặn nhầm / bỏ lọt bao nhiêu? | mốc 87,4% (2.2) · ma trận nhầm lẫn (9.2) · ngưỡng (9.3) | accuracy cao mà vẫn bỏ lọt cả loạt tin rác |\n",
"| 🤙 **5. Ngón út** | **GIẢI THÍCH & CẢNH GIÁC** | Vì sao máy chặn? Kẻ tấn công né được không? Ai xem vùng giữa? | từ \"tố cáo\" (3.1, 7.2) · chèn từ an toàn (10) · làn cách ly cho người xem (6.3) | Naive Bayes từ 59% tụt còn 3% khi bị chèn từ |\n",
"\n",
"### 11.1 Tương lai và báo cáo\n",
"\n",
"**Tương lai (2026):** kẻ tấn công dùng mô hình ngôn ngữ lớn (LLM) để viết tin / email **không lỗi chính tả, đúng văn phong** — những từ \"đáng ngờ\" mà các mô hình trong bài dựa vào sẽ ít dần. Phòng thủ đang chuyển sang: LLM phía phòng thủ đọc **ngữ cảnh và ý định**, kết hợp **hành vi** (người gửi có hay gửi cho người nhận này không? link dẫn đi đâu? giờ gửi có lạ không?) với các mô hình như trong bài."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from pathlib import Path\n",
"THU_MUC_RA = Path.home() / \"lab\" / \"out_lab4\"\n",
"THU_MUC_RA.mkdir(parents=True, exist_ok=True)\n",
"hd = tong_hop.set_index(\"mô hình\").loc[f\"Hội đồng trọng số {TRONG_SO}\"]\n",
"luat = tong_hop.set_index(\"mô hình\").loc[f\"Luật {len(TU_KHOA)} từ khóa (Phần 1)\"]\n",
"bao_cao = f'''BÁO CÁO MODULE 4: LỌC TIN NHẮN RÁC / LỪA ĐẢO BẰNG MÁY HỌC (SMS Spam Collection, {len(tin):,} tin thật không trùng)\n",
"=====================================================================================\n",
"Đề thi : {len(y_thi)} tin, {int(y_thi.sum())} tin rác\n",
"Luật từ khóa : F1 {luat['F1']:.3f} | chặn nhầm {int(luat['chặn nhầm (FP)'])} | bỏ lọt {int(luat['bỏ lọt (FN)'])}\n",
"Hội đồng trọng số {TRONG_SO} : F1 {hd['F1']:.3f} | precision {hd['precision']:.3f} | recall {hd['recall']:.3f} | chặn nhầm {int(hd['chặn nhầm (FP)'])} | bỏ lọt {int(hd['bỏ lọt (FN)'])}\n",
"Tấn công chèn từ an toàn: hội đồng bắt được {tan_cong.iloc[-1, 0]:.0%} -> {tan_cong.iloc[-1, 1]:.0%} tin rác\n",
"\n",
"Khuyến nghị:\n",
"- Thay luật từ khóa bằng mô hình học máy; dùng hội đồng có trọng số, kiểm tra bằng số liệu trên đề thi riêng.\n",
"- Phân luồng 3 làn theo xác suất: chặn khi rất chắc, cách ly vùng giữa cho người xem, cho qua phần còn lại.\n",
"- Luôn bỏ bản sao trước khi chia học / thi; luôn so với mốc \"lười\".\n",
"- Tập lại định kỳ với các chiêu né tránh mới (huấn luyện đối kháng); kết hợp thêm tín hiệu hành vi (người gửi, link, giờ gửi).\n",
"'''\n",
"(THU_MUC_RA / \"bao_cao_lab4.txt\").write_text(bao_cao, encoding=\"utf-8\")\n",
"print(bao_cao)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"**Kết quả:** báo cáo in ra và lưu ở `~/lab/out_lab4/bao_cao_lab4.txt`: `Đề thi 1293 tin, 163 tin rác` · `Luật từ khóa: F1 0.665 | chặn nhầm 46 | bỏ lọt 59` · `Hội đồng trọng số [2, 1, 1]: F1 0.933 | precision 0.973 | recall 0.896 | chặn nhầm 4 | bỏ lọt 17` · `Tấn công chèn từ an toàn: hội đồng bắt được 90% -> 66% tin rác`, rồi 4 khuyến nghị.\n",
"\n",
"👉 **Hiểu:** đổi bất kỳ ô ✏️ nào ở trên (từ khóa, ngưỡng, trọng số, câu chèn) rồi **Run All** → báo cáo tự cập nhật theo."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"**Câu hỏi suy nghĩ:**\n",
"1. Ở Phần 1, thêm từ khóa nào thì bắt thêm được tin rác mà không chặn nhầm tin thật? Có làm được mãi không?\n",
"2. Ở SOC của bạn, bỏ lọt 1 tin lừa đảo hay chặn nhầm 1 email của sếp tốn kém hơn? Bạn chọn ngưỡng nào ở 6.3 / 9.3?\n",
"3. Ở 8.2, đổi `TRONG_SO` thành `[1, 1, 1]` và `[3, 1, 1]`: hội đồng có luôn giỏi hơn mô hình giỏi nhất không?\n",
"4. Ở 10.1, thử viết `CAU_CHEN` của riêng bạn. Mô hình nào \"ngây thơ\" nhất? Vì sao?"
]
}
],
"nbformat": 4,
"nbformat_minor": 5,
"metadata": {
"kernelspec": {
"name": "csai",
"display_name": "Python (lớp CSAI)",
"language": "python"
},
"language_info": {
"name": "python"
}
}
}