{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Module 10 — Mô hình đã \"đủ tốt\" chưa? Đánh giá lại, cải thiện, và những gì phải chấp nhận\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**. Cả notebook chạy hết khoảng **5–10 phút** (Phần 5 và 6 lâu nhất).\n", "\n", "Ở các bài trước ta đã xây nhiều mô hình. Câu hỏi của bài này là câu hỏi **thật** mà ai làm máy học cũng gặp:\n", "\n", "> *\"Mô hình (SVM của Lab 4) bỏ lọt 20 tin lừa đảo. Làm sao cho nó **không bỏ lọt tin nào** (FN = 0)?\"*\n", "> *\"Chưa đạt kỳ vọng thì làm gì: thêm dữ liệu? thêm đặc trưng? đổi mô hình?\"*\n", "\n", "### 🎯 Mục tiêu của bài\n", "\n", "Học xong bài này, bạn:\n", "1. Đánh giá đúng **điểm xuất phát**: cái bẫy \"accuracy cao\", và nhân lỗi lên **quy mô thật** (Phần 0).\n", "2. **Đo cho chắc** bằng kiểm định chéo trước khi tin một chênh lệch (Phần 1).\n", "3. Trả lời câu \"**FN = 0 được không?**\" bằng ngưỡng, đường ROC / PR và **chi phí** (Phần 2).\n", "4. Thử **4 đòn bẩy** cải thiện — thêm dữ liệu, thêm đặc trưng, đổi mô hình, tinh chỉnh tham số — và đo cái nào thật sự giúp (Phần 3 – 6).\n", "5. **Đọc lỗi còn lại**, chuyển phần khó cho người xem; thử lừa mô hình và thấy dữ liệu trôi (Phần 7, 8).\n", "6. **Tổng hợp cả khoá**: chọn mô hình theo 4 câu hỏi, khi nào dùng gì, phải chấp nhận gì (Phần 9).\n", "\n", "### 🗺️ Bài này: bài toán gì, mô hình nào, vì sao?\n", "\n", "- **Bài toán:** vẫn bài **phân loại 2 lớp** tin rác / tin thường của Lab 4, nhưng câu hỏi khác: mô hình **đã đủ tốt chưa**, và **cải thiện bằng cách nào** cho đáng công.\n", "- **Dữ liệu:** **văn bản có nhãn** (bộ của Lab 4), bỏ bản sao và chia học / thi y hệt Lab 4.\n", "- **Vì vậy chọn:** mốc = **SVM của Lab 4**; rồi so nhiều cách nhìn văn bản và nhiều mô hình **trên cùng dữ liệu**, chấm bằng kiểm định chéo.\n", "- **Xử lý gì trước:** **đo cho chắc** (Phần 1) trước khi so cải thiện — nếu không, chênh lệch có thể chỉ là may rủi khi chia dữ liệu.\n", "- **Thước đo:** **chi phí** của chặn nhầm (FP) và bỏ lọt (FN), không chỉ accuracy.\n", "\n", "Ta trả lời **bằng số đo**, không bằng lời khuyên chung chung. 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 thực hành:** đúng bộ *SMS Spam Collection* của Lab 4 — **5.574 tin nhắn SMS thật** (đếm ở Lab 4) đã gắn nhãn `ham` (tin thật) / `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. Ta bỏ bản sao và chia học / thi **y hệt Lab 4**, nên điểm xuất phát chính là mô hình SVM của Lab 4. Phần 9 tổng hợp thêm các mô hình trên **dữ liệu honeypot** ở những notebook khác.\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, **có nhãn không**? (bộ tin nhắn của Lab 4) | Phần 0 (thẻ dữ liệu), 0.1 |\n", "| 2. **Làm sạch dữ liệu** | bỏ bản sao trước khi chia — y hệt Lab 4 | 0.1 |\n", "| 3. **Chọn mô hình phù hợp với bài toán** | điểm xuất phát tốt đến đâu, nên thử những gì, theo thứ tự nào? | 0.2, 0.3, Phần 5 (7 mô hình) |\n", "| 4. **Coding** | thêm dữ liệu, thêm đặc trưng, đổi mô hình, tinh chỉnh tham số | Phần 3, 4, 5, 6 |\n", "| 5. **Lưu ý khi coding** | lỗi nào làm điểm đẹp **ảo** mà không báo lỗi? | Phần 1 (một lần chia chưa đủ), 4.3 (tốt thật hay may), 6.1 (không tinh chỉnh trên đề thi) |\n", "| 6. **Đánh giá các mô hình** | đo theo chi phí, đọc lỗi còn lại, thử lừa mô hình, dữ liệu trôi | Phần 2, 7, 8 |\n", "| 7. **Quy tắc bàn tay phải** ✋** | tổng hợp cả khoá, báo cáo | Phần 9, 10 |\n", "\n", "📌 **Tra cứu nhanh:** *Chọn mô hình nào? Khi nào dùng gì, đánh đổi gì* — bài này là bài **tổng kết khoá**, nên bảng tra đầy đủ nằm ở **9.2**.\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", "| **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 = TP / (TP + FP) |\n", "| **recall** | trong các tin spam **thật**, máy bắt được bao nhiêu phần = TP / (TP + FN) |\n", "| **F1** | một con số gộp precision và recall (cao khi cả hai cùng cao) |\n", "| **FPR** (tỉ lệ báo nhầm) | trong các tin **thật**, bao nhiêu phần bị chặn nhầm = FP / (FP + TN) |\n", "| **FNR** (tỉ lệ bỏ lọt) | trong các tin **rác**, bao nhiêu phần bị lọt = FN / (FN + TP) = 1 − recall |\n", "| **ngưỡng** (threshold) | điểm cắt: tin có điểm > ngưỡng thì chặn. Đổi ngưỡng = đổi FP lấy FN |\n", "| **kiểm định chéo** (cross-validation, CV) | chia phần học thành K phần, lần lượt lấy 1 phần làm \"đề thi thử\" — K điểm thay vì 1 |\n", "| **tham số tinh chỉnh** (hyperparameter) | núm vặn của mô hình mà **người** chọn trước khi học (ví dụ `C` của SVM, độ sâu của cây) |\n", "| **quá khớp** (overfitting) | học thuộc phần học, làm bài mới kém |\n", "\n", "### 🐍 Cú pháp Python hay gặp trong bài\n", "\n", "| Viết | Nghĩa |\n", "|---|---|\n", "| `make_pipeline(A, B)` | nối các bước thành **một ống**: dữ liệu đi qua A rồi B. `fit` cả ống = học từng bước theo thứ tự |\n", "| `cross_val_score(ong, X, y, cv=..., scoring=\"f1\")` | chạy kiểm định chéo, trả về **mảng K điểm F1** |\n", "| `mang.mean()`, `mang.std()` | trung bình, độ lệch chuẩn (độ dao động) của một mảng số |\n", "| `(diem > 0.5)` | so sánh cả mảng một lúc → mảng True/False; `.sum()` đếm số True |\n", "| `for ten, ong in tu_dien.items():` | lặp qua từng cặp (tên, giá trị) của một từ điển |\n", "| `pd.DataFrame(danh_sach_dict)` | biến danh sách các dict (mỗi dict 1 dòng) thành bảng |\n", "| `f\"{x:.3f}\"`, `f\"{x:.1%}\"` | in số với 3 chữ số thập phân / dạng phần trăm 1 chữ số |\n", "\n", "---\n", "\n", "## Phần 0 — Thu thập dữ liệu và điểm xuất phát\n", "\n", "📍 **Bước 1 + 2 — Thu thập và làm sạch:** dùng lại đúng dữ liệu và cách làm sạch của Lab 4 (thẻ dữ liệu đầy đủ: Lab 4, đầu Phần 0).\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**: tin nhắn SMS tiếng Anh, mỗi tin một nhãn `ham` / `spam` |\n", "| **Lấy ở đâu?** | bộ *SMS Spam Collection v.1* (T. A. Almeida & J. M. Gómez Hidalgo, 2011) — file `/opt/csai/labs/day4_phishing/SMSSpamCollection` |\n", "| **Thu thập thế nào?** | tin rác chép từ diễn đàn người dùng đăng tin rác nhận được; tin thường do người tình nguyện chia sẻ (chi tiết ở Lab 4, đầu Phần 0) |\n", "| **Có nhãn chưa?** | **có**, do tác giả bộ dữ liệu gắn theo nguồn → học **có giám sát** |\n", "| **Có cần làm sạch?** | **có**: bỏ bản sao trước khi chia (0.1, y hệt Lab 4 0.2) |\n", "\n", "### 0.1 Dựng lại mô hình của Lab 4\n", "\n", "Bỏ bản sao, chia 75% học / 25% thi với cùng \"hạt giống\" ngẫu nhiên, biến chữ thành số bằng TF-IDF, học SVM — **giống hệt Lab 4**. Hàm `cham_diem` đếm 4 ô của ma trận nhầm lẫn và tính mọi chỉ số, dùng lại suốt bài." ] }, { "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", "from sklearn.model_selection import train_test_split\n", "from sklearn.feature_extraction.text import TfidfVectorizer\n", "from sklearn.svm import LinearSVC\n", "pd.set_option(\"display.max_colwidth\", 110)\n", "pd.set_option(\"display.width\", 200)\n", "warnings.filterwarnings(\"ignore\", category=UserWarning) # ẩn cảnh báo kỹ thuật không ảnh hưởng kết quả\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ả (giống Lab 4)\n", "\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", "tin = du_lieu.drop_duplicates(\"noi_dung\").reset_index(drop=True) # bỏ bản sao TRƯỚC khi chia (bài học Lab 4)\n", "y = (tin.nhan == \"spam\").astype(int) # 1 = spam, 0 = ham\n", "vi_tri_hoc, vi_tri_thi = train_test_split(tin.index, test_size=0.25, random_state=RANDOM_STATE, stratify=y)\n", "van_ban_hoc, van_ban_thi = tin.noi_dung[vi_tri_hoc], tin.noi_dung[vi_tri_thi]\n", "y_hoc, y_thi = y[vi_tri_hoc], y[vi_tri_thi]\n", "# 📦 van_ban_hoc / van_ban_thi = nội dung tin nhắn phần học / phần thi · y_hoc / y_thi = nhãn 0/1 tương ứng\n", "print(f\"Phần học: {len(van_ban_hoc)} tin ({y_hoc.sum()} spam) | Phần thi: {len(van_ban_thi)} tin ({y_thi.sum()} spam)\")\n", "\n", "def cham_diem(y_that, du_doan):\n", " # Đếm 4 ô của ma trận nhầm lẫn rồi tính các tỉ lệ. y_that, du_doan: mảng 0/1 cùng độ dài\n", " y_that, du_doan = np.asarray(y_that), np.asarray(du_doan)\n", " tp = int(((du_doan == 1) & (y_that == 1)).sum()) # bắt đúng spam\n", " fp = int(((du_doan == 1) & (y_that == 0)).sum()) # chặn nhầm tin thật\n", " fn = int(((du_doan == 0) & (y_that == 1)).sum()) # bỏ lọt spam\n", " tn = int(((du_doan == 0) & (y_that == 0)).sum()) # cho qua đúng tin thật\n", " precision = tp / (tp + fp) if tp + fp else 0.0\n", " recall = tp / (tp + fn) if tp + fn else 0.0\n", " return {\"TP\": tp, \"FP\": fp, \"FN\": fn, \"TN\": tn, \"precision\": precision, \"recall\": recall,\n", " \"F1\": 2 * precision * recall / (precision + recall) if precision + recall else 0.0,\n", " \"FPR\": fp / (fp + tn), \"FNR\": fn / (fn + tp), \"accuracy\": (tp + tn) / len(y_that)}\n", "\n", "tfidf_goc = TfidfVectorizer() # từ điển từ đơn, như Lab 4\n", "X_hoc_goc = tfidf_goc.fit_transform(van_ban_hoc) # fit = học từ điển từ phần học\n", "X_thi_goc = tfidf_goc.transform(van_ban_thi) # phần thi chỉ transform\n", "svm_goc = LinearSVC(random_state=RANDOM_STATE).fit(X_hoc_goc, y_hoc)\n", "du_doan_goc = svm_goc.predict(X_thi_goc)\n", "kq_goc = cham_diem(y_thi, du_doan_goc)\n", "# 📦 kq_goc = dict mọi chỉ số của mô hình gốc trên phần thi\n", "print(pd.DataFrame([[kq_goc[\"TN\"], kq_goc[\"FP\"]], [kq_goc[\"FN\"], kq_goc[\"TP\"]]],\n", " index=[\"thật là ham\", \"thật là spam\"], columns=[\"máy cho qua\", \"máy chặn\"]))\n", "print({k: round(v, 4) for k, v in kq_goc.items()})" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "**Kết quả:** `Phần học: 3878 tin (490 spam) | Phần thi: 1293 tin (163 spam)`, ma trận nhầm lẫn:\n", "```\n", " máy cho qua máy chặn\n", "thật là ham 1127 3\n", "thật là spam 20 143\n", "```\n", "và `precision 0.9795 · recall 0.8773 · F1 0.9256 · FPR 0.0027 · FNR 0.1227 · accuracy 0.9822`.\n", "\n", "📦 **`kq_goc`** = dict mọi chỉ số của mô hình gốc · **`svm_goc`**, **`tfidf_goc`** = mô hình và từ điển của Lab 4 · **`cham_diem(y_that, du_doan)`** = hàm chấm, dùng lại cả bài.\n", "\n", "👉 **Hiểu:** đúng con số của Lab 4: chặn nhầm **3**, bỏ lọt **20** tin rác trên 163. Đây là \"điểm xuất phát\" — mọi đòn bẩy phía sau đều so với nó." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 0.2 Cái bẫy \"accuracy cao\" và phép nhân với quy mô thật\n", "\n", "📍 **Bước 6 — Đánh giá:** điểm xuất phát tốt đến đâu, khi nhân lên quy mô thật?\n", "\n", "**Accuracy** (tỉ lệ đoán đúng) là con số dễ hiểu nhất nhưng **dễ lừa nhất** khi một lớp hiếm: một \"mô hình\" lười **luôn trả lời ham** đã đúng gần 90% mà không bắt được tin rác nào.\n", "\n", "Con số nhỏ trên giấy thành con số lớn ngoài đời. Một tổ chức nhận **100.000 tin / email mỗi ngày**: tỉ lệ báo nhầm 0,3% nghe rất nhỏ, nhưng là **hàng trăm tin thật bị chặn mỗi ngày** — mỗi tin là một khách hàng, một hợp đồng, một cuộc gọi lên bộ phận hỗ trợ." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# ✏️ SỬA: quy mô của tổ chức bạn và tỉ lệ tin rác thực tế\n", "SO_TIN_MOI_NGAY = 100_000\n", "TI_LE_SPAM_THUC_TE = 0.126 # bằng tỉ lệ trong bộ dữ liệu; thư điện tử thật thường cao hơn nhiều\n", "\n", "acc_luoi = (y_thi == 0).mean() # mô hình lười: luôn đoán 0 (ham)\n", "print(f\"Mô hình lười 'luôn là ham': accuracy {acc_luoi:.1%}, bắt được 0 / {y_thi.sum()} tin rác\")\n", "print(f\"SVM của Lab 4 : accuracy {kq_goc['accuracy']:.1%}, bắt được {kq_goc['TP']} / {y_thi.sum()} tin rác\")\n", "\n", "tin_that_ngay = SO_TIN_MOI_NGAY * (1 - TI_LE_SPAM_THUC_TE)\n", "tin_rac_ngay = SO_TIN_MOI_NGAY * TI_LE_SPAM_THUC_TE\n", "print(f\"\\nMỗi ngày {SO_TIN_MOI_NGAY:,} tin = {tin_that_ngay:,.0f} tin thật + {tin_rac_ngay:,.0f} tin rác\")\n", "print(f\"FPR {kq_goc['FPR']:.2%} -> chặn nhầm khoảng {tin_that_ngay * kq_goc['FPR']:,.0f} tin THẬT mỗi ngày\")\n", "print(f\"FNR {kq_goc['FNR']:.2%} -> bỏ lọt khoảng {tin_rac_ngay * kq_goc['FNR']:,.0f} tin RÁC mỗi ngày\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "**Kết quả:** `Mô hình lười 'luôn là ham': accuracy 87.4%, bắt được 0 / 163 tin rác` · `SVM của Lab 4: accuracy 98.2%, bắt được 143 / 163` · `Mỗi ngày 100,000 tin = 87,400 tin thật + 12,600 tin rác` · `FPR 0.27% -> chặn nhầm khoảng 232 tin THẬT mỗi ngày` · `FNR 12.27% -> bỏ lọt khoảng 1,546 tin RÁC mỗi ngày`\n", "\n", "👉 **Hiểu:**\n", "- Mô hình **không làm gì** đã \"đúng 87,4%\". Accuracy 98,2% nghe gần hoàn hảo, nhưng vẫn bỏ lọt **1 trên 8** tin rác.\n", "- Nhân lên quy mô thật: **~230 tin thật bị chặn** và **~1.500 tin rác lọt qua mỗi ngày**. Vì vậy trong bảo mật ta luôn nói bằng **FP, FN, FPR, FNR**, không nói bằng accuracy." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 0.2b 🧠 Quy tắc bỏ túi: 4 chỉ số hay nhầm — hỏi gì, tính thế nào, khi nào ưu tiên\n", "\n", "📍 **Bước 6 — Đánh giá (ôn nhanh):** vừa thấy accuracy lừa ở 0.2 — đây là bảng để **nhớ** cả 4 chỉ số.\n", "\n", "| Chỉ số | Trả lời câu hỏi | Công thức dễ nhớ | Khi nào ưu tiên? |\n", "|---|---|---|---|\n", "| **Accuracy** *(độ chính xác)* | Đoán đúng bao nhiêu % trên **tổng số mẫu**? | (TP + TN) / tổng số mẫu | Chỉ dùng khi **dữ liệu cân bằng** (các nhóm ngang nhau) |\n", "| **Precision** *(độ chuẩn xác)* | Trong những lần máy **bắn cảnh báo**, bao nhiêu % là **đúng thật**? | TP / (TP + FP) = bắt đúng / **tổng số cảnh báo** | Cần **ít báo nhầm (FP)**: bộ lọc thư rác không được chặn nhầm thư đối tác |\n", "| **Recall** *(độ nhạy / độ phủ)* | Trong **tất cả mối nguy thật**, máy **tóm được** bao nhiêu %? | TP / (TP + FN) = bắt đúng / **tổng số nguy cơ thật** | **Không được bỏ sót (FN)**: mã độc, xâm nhập mạng |\n", "| **F1** | Điểm **cân bằng** giữa Precision và Recall | 2 × P × R / (P + R) | Dữ liệu **lệch** và cần **một con số** để so mô hình |\n", "\n", "**Mẹo nhớ mẫu số** — nhìn lại ma trận nhầm lẫn in ở 0.1 (hàng = **thật là…**, cột = **máy…**):\n", "- **Precision** đọc theo **cột \"máy chặn\"**: trong những gì máy chặn, bao nhiêu đúng? → mẫu số TP + **FP**.\n", "- **Recall** đọc theo **hàng \"thật là spam\"**: trong những gì thật sự xấu, máy bắt được bao nhiêu? → mẫu số TP + **FN**.\n", "\n", "**Ví dụ 30 giây** (kịch bản giả định, ô dưới tự tính): quét **1.000.000 file mỗi ngày**, thật ra chỉ **100 file là mã độc**. Một mô hình lười luôn kết luận *\"mọi file đều sạch\"*. Chạy ô dưới để xem 4 chỉ số của nó." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# ✏️ SỬA: kịch bản quét file — đổi số rồi chạy lại ô này\n", "SO_FILE = 1_000_000 # số file quét mỗi ngày\n", "SO_MALWARE = 100 # trong đó thật sự là mã độc\n", "BAT_DUOC = 0 # mô hình bắt đúng bao nhiêu mã độc (0 = mô hình lười \"mọi file đều sạch\"); tối đa = SO_MALWARE\n", "BAO_NHAM = 0 # mô hình cảnh báo nhầm bao nhiêu file sạch\n", "\n", "y_file = np.zeros(SO_FILE, dtype=int)\n", "y_file[:SO_MALWARE] = 1 # nhãn thật: SO_MALWARE file đầu là mã độc (1), còn lại sạch (0)\n", "du_doan_file = np.zeros(SO_FILE, dtype=int) # bắt đầu: máy nói \"sạch\" cho mọi file\n", "du_doan_file[:BAT_DUOC] = 1 # máy bắt đúng BAT_DUOC file mã độc\n", "du_doan_file[SO_MALWARE:SO_MALWARE + BAO_NHAM] = 1 # máy cảnh báo nhầm BAO_NHAM file sạch\n", "kq_file = cham_diem(y_file, du_doan_file) # dùng lại hàm chấm điểm của 0.1\n", "# 📦 kq_file = dict TP / FP / FN / TN + các chỉ số của kịch bản này\n", "print(f\"TP (bắt đúng) {kq_file['TP']} · FP (báo nhầm) {kq_file['FP']} · FN (bỏ sót) {kq_file['FN']} · TN {kq_file['TN']:,}\")\n", "print(f\"Accuracy = {kq_file['accuracy']:.2%}\")\n", "print(f\"Precision = {kq_file['precision']:.2%} (0 khi máy không cảnh báo file nào)\")\n", "print(f\"Recall = {kq_file['recall']:.2%} -> tóm được {kq_file['TP']} / {SO_MALWARE} mã độc\")\n", "print(f\"F1 = {kq_file['F1']:.3f}\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "**Kết quả:** `TP (bắt đúng) 0 · FP (báo nhầm) 0 · FN (bỏ sót) 100 · TN 999,900` · `Accuracy = 99.99%` · `Precision = 0.00%` · `Recall = 0.00% -> tóm được 0 / 100 mã độc` · `F1 = 0.000`\n", "\n", "👉 **Hiểu:**\n", "- Mô hình **không làm gì** vẫn \"đúng 99,99%\", vì 999.900 / 1.000.000 file vốn sạch. Nhưng Recall = 0: **bỏ sót cả 100 mã độc**. F1 = 0 vạch trần ngay.\n", "- Precision ở đây là 0 vì máy **không cảnh báo file nào** (0 / 0, quy ước tính là 0).\n", "\n", "✏️ **Thử sửa** (đã đo trên máy lớp):\n", "- `BAT_DUOC = 90`, `BAO_NHAM = 1000` → Accuracy 99,90% · Precision **8,26%** · Recall **90%** · F1 0,151: bắt gần hết, nhưng cứ 12 cảnh báo mới có 1 cái đúng → SOC ngập báo giả.\n", "- `BAT_DUOC = 100`, `BAO_NHAM = 9900` → Accuracy vẫn 99,01% · Recall 100% · Precision **1,00%** · F1 0,020: \"bắt hết\" bằng cách báo động bừa — Accuracy vẫn trông đẹp.\n", "- `BAT_DUOC = 60`, `BAO_NHAM = 0` → Precision 100% · Recall **60%** · F1 0,750 (Accuracy 99,996% nhưng in ra `100.00%` vì làm tròn 2 chữ số): báo phát nào trúng phát nấy, nhưng vẫn lọt 40 mã độc." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "**🗣️ Câu thần chú** — đọc to một lần, nhớ cả khóa:\n", "- **Accuracy:** *\"Đừng để con số 99% đánh lừa khi dữ liệu lệch!\"*\n", "- **Precision:** *\"Báo phát nào, trúng phát nấy (không báo ảo).\"*\n", "- **Recall:** *\"Thà bắt nhầm còn hơn bỏ sót (không lọt lưới).\"*\n", "- **F1:** *\"Trọng tài công tâm giữa Báo nhầm (Precision) và Bỏ sót (Recall).\"*" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 0.2c 🔧 Chỉ số nào kém → phải sửa gì? (bản đồ cải thiện)\n", "\n", "📍 **Bước 6 → Bước 3:** đánh giá xong thì **chẩn đoán** trước, rồi mới chọn cách sửa. Đừng sửa mò.\n", "\n", "**Quy tắc bập bênh ⚖️:** **ngưỡng** giống cái bập bênh — **nâng ngưỡng** (chỉ chặn khi rất chắc) thì **ít báo nhầm, nhưng bỏ sót nhiều hơn**; **hạ ngưỡng** thì ngược lại. Đổi ngưỡng chỉ là **đổi FP lấy FN**. Muốn **cả hai cùng tốt** thì mô hình phải **\"nhìn rõ hơn\"**: đặc trưng tốt hơn, dữ liệu nhiều/sạch hơn, mô hình hợp hơn.\n", "\n", "| Bạn thấy gì (triệu chứng) | Chỉ số đang kém | Thay đổi gì | Ví dụ đời thường | Làm ở phần |\n", "|---|---|---|---|---|\n", "| Người dùng kêu **thư thật bị chặn**, SOC ngập **cảnh báo giả** | **Precision** thấp (FP nhiều) | **Nâng ngưỡng**; thêm đặc trưng phân biệt; ca lưng chừng chuyển **người xem** | Chuông báo cháy kêu mỗi lần **chiên trứng** → chỉnh bớt nhạy, đặt xa bếp | Phần 2, 4, 7 |\n", "| **Mã độc / tin rác lọt qua** | **Recall** thấp (FN nhiều) | **Hạ ngưỡng** (chấp nhận thêm FP); thêm **mẫu xấu có nhãn**; thêm đặc trưng | **Lưới mắt to** → cá nhỏ lọt; đan mắt nhỏ hơn thì bắt đủ cá nhưng vớt thêm rác | Phần 2, 3, 4 |\n", "| **Cả hai cùng kém** (F1 thấp), đổi ngưỡng kiểu gì cũng dở | Mô hình **chưa tách được** hai nhóm | **Đặc trưng mới**, **đổi mô hình**, tinh chỉnh, kiểm tra **nhãn sai** | Bảo vệ chỉ nhìn **màu áo** để bắt trộm → vặn \"độ nghi ngờ\" cũng vô ích; phải nhìn thêm **mặt, thẻ ra vào** | Phần 4, 5, 6, 7 |\n", "| **Accuracy rất cao** mà không bắt được gì | Đang **nhìn sai thước đo** (dữ liệu lệch) | **Đổi thước đo** sang Recall / Precision / F1, chưa cần đổi mô hình | Ví dụ 1.000.000 file ở 0.2b: \"đúng 99,99%\" mà bắt **0 / 100** mã độc | 0.2, 0.2b |\n", "| **Điểm nhảy lung tung** mỗi lần chạy lại | **Đo chưa chắc** | **Kiểm định chéo**, so trên **cùng** cách chia, xem ± | **Cân một lần** đọc số lẻ → cân 5 lần lấy trung bình | Phần 1 |\n", "| **Học thì giỏi, thi / ra thật thì kém** | **Quá khớp**, đề **bị lộ**, hoặc **dữ liệu trôi** | Bỏ bản trùng giữa học và thi, thêm dữ liệu, **theo dõi và học lại** | Học sinh **học thuộc đề cũ** → đề mới là trượt | Phần 1, 3, 8 |\n", "\n", "👉 **Nhớ một câu:** *\"Chẩn đoán bằng chỉ số, chữa bằng đúng thuốc: báo nhầm nhiều → nâng ngưỡng; bỏ sót nhiều → hạ ngưỡng; cả hai cùng dở → làm mô hình nhìn rõ hơn.\"*" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 0.3 \"Chưa đạt kỳ vọng\" — bản đồ các việc có thể làm\n", "\n", "📍 **Bước 3 — Chọn hướng cải thiện.** Bảng dưới là **thứ tự việc** bên trong bước 4 – 6 của pipeline (đừng nhầm với 7 bước của pipeline).\n", "\n", "Trước khi lao vào đổi mô hình, đi theo thứ tự này (rẻ trước, đắt sau). Mỗi bước là một phần của bài:\n", "\n", "| Thứ tự việc | Câu hỏi | Phần |\n", "|---|---|---|\n", "| 1. Đo cho chắc | điểm chênh lệch mình thấy có **thật** không, hay chỉ do may rủi khi chia dữ liệu? | 1 |\n", "| 2. Định nghĩa \"tốt\" | lỗi nào **đắt** hơn: chặn nhầm (FP) hay bỏ lọt (FN)? → chọn **ngưỡng**, không đổi mô hình | 2 |\n", "| 3. Chẩn đoán | thiếu **dữ liệu** hay thiếu **thông tin** (đặc trưng)? — đường cong học trả lời | 3 |\n", "| 4. Thêm đặc trưng | cho máy \"nhìn\" thấy thứ con người nhìn thấy | 4 |\n", "| 5. Đổi mô hình | mô hình khác có hợp dạng dữ liệu hơn không? (văn bản, bảng số, chuỗi thời gian...) | 5 |\n", "| 6. Tinh chỉnh | vặn núm của mô hình — **chỉ** bằng kiểm định chéo trên phần học | 6 |\n", "| 7. Đọc lỗi còn lại | lỗi nào sửa được, lỗi nào **phải chấp nhận** → chuyển cho người xem | 7 |\n", "\n", "---\n", "\n", "## Phần 1 — Đo cho chắc\n", "\n", "📍 **Bước 5 — Lưu ý khi coding:** một lần chia học / thi cho một con số may rủi — code không báo lỗi, nhưng so sánh có thể sai.\n", "\n", "### 1.1 Chỉ đổi cách chia, điểm thay đổi bao nhiêu?\n", "\n", "Điểm F1 ở 0.1 đo trên **một** đề thi. Nếu chia ngẫu nhiên theo cách khác (đổi \"hạt giống\"), cùng một mô hình sẽ ra điểm khác. Ta chia 10 lần, mỗi lần học lại và chấm lại.\n", "\n", "`make_pipeline(TfidfVectorizer(), LinearSVC())` nối 2 bước thành **một ống**: `fit` ống = học từ điển rồi học SVM, **chỉ** trên phần học của lần chia đó (không để từ điển \"nhìn thấy\" đề thi)." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from sklearn.pipeline import make_pipeline\n", "# ✏️ SỬA: số lần chia thử\n", "SO_LAN_CHIA = 10\n", "f1_moi_lan_chia = [] # 📦 danh sách F1, mỗi lần chia 1 số\n", "for hat_giong in range(SO_LAN_CHIA):\n", " vt_hoc_k, vt_thi_k = train_test_split(tin.index, test_size=0.25, random_state=hat_giong, stratify=y)\n", " ong_k = make_pipeline(TfidfVectorizer(), LinearSVC(random_state=RANDOM_STATE)).fit(tin.noi_dung[vt_hoc_k], y[vt_hoc_k])\n", " f1_lan_nay = cham_diem(y[vt_thi_k], ong_k.predict(tin.noi_dung[vt_thi_k]))[\"F1\"]\n", " f1_moi_lan_chia.append(f1_lan_nay)\n", " print(f\"hạt giống {hat_giong}: F1 = {f1_lan_nay:.3f}\")\n", "print(f\"\\nThấp nhất {min(f1_moi_lan_chia):.3f} | cao nhất {max(f1_moi_lan_chia):.3f} | chênh {max(f1_moi_lan_chia) - min(f1_moi_lan_chia):.3f}\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "**Kết quả:** 10 dòng `hạt giống 0: F1 = 0.943` ... `hạt giống 7: F1 = 0.906` ... rồi `Thấp nhất 0.906 | cao nhất 0.959 | chênh 0.053`\n", "\n", "📦 **`f1_moi_lan_chia`** = 10 điểm F1, mỗi cách chia một số.\n", "\n", "👉 **Hiểu:** **cùng** dữ liệu, **cùng** mô hình, chỉ đổi cách chia ngẫu nhiên mà F1 chạy từ 0,906 đến 0,959 — chênh hơn **5 điểm phần trăm**. Nếu bạn đổi mô hình và thấy F1 tăng 0,01, chưa chắc là nhờ mô hình. Một đề thi duy nhất **không đủ** để so sánh." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 1.2 Kiểm định chéo K phần (Stratified K-Fold)\n", "\n", "Cách đo chuẩn: chia **phần học** thành K = 5 phần bằng nhau (giữ tỉ lệ spam như nhau — chữ *Stratified*). Lần lượt mỗi phần làm \"đề thi thử\", 4 phần còn lại để học:\n", "\n", "```\n", "lần 1: [THI] [học] [học] [học] [học]\n", "lần 2: [học] [THI] [học] [học] [học]\n", " ...\n", "lần 5: [học] [học] [học] [học] [THI]\n", "```\n", "\n", "Được **5 điểm**: trung bình cho biết mô hình tốt cỡ nào, **độ lệch chuẩn** cho biết điểm dao động cỡ nào. **Phần thi thật (25%) vẫn để nguyên**, chỉ dùng ở bước cuối." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from sklearn.model_selection import StratifiedKFold, cross_val_score\n", "chia_5_phan = StratifiedKFold(n_splits=5, shuffle=True, random_state=RANDOM_STATE) # cách chia, dùng lại cả bài\n", "ong_goc = make_pipeline(TfidfVectorizer(), LinearSVC(random_state=RANDOM_STATE)) # đúng mô hình 0.1, dạng ống\n", "f1_5_phan = cross_val_score(ong_goc, van_ban_hoc, y_hoc, cv=chia_5_phan, scoring=\"f1\")\n", "# 📦 f1_5_phan = mảng 5 điểm F1, mỗi phần 1 điểm\n", "print(\"F1 từng phần:\", np.round(f1_5_phan, 3))\n", "print(f\"F1 kiểm định chéo = {f1_5_phan.mean():.3f} ± {f1_5_phan.std():.3f}\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "**Kết quả:** `F1 từng phần: [0.93 0.909 0.914 0.93 0.968]` · `F1 kiểm định chéo = 0.930 ± 0.021`\n", "\n", "📦 **`chia_5_phan`** = cách chia 5 phần (dùng lại cả bài, nên mọi so sánh phía sau trên **cùng** 5 phần) · **`ong_goc`** = ống TF-IDF + SVM · **`f1_5_phan`** = 5 điểm.\n", "\n", "👉 **Hiểu:** mô hình gốc \"đáng\" khoảng **0,93 ± 0,02**. Quy tắc nhớ: chênh lệch giữa hai mô hình **nhỏ hơn cỡ ± này** thì chưa phải là khác biệt thật (Phần 4.3 có cách kiểm tra chặt hơn)." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "---\n", "\n", "## Phần 2 — \"Làm sao cho FN = 0?\"\n", "\n", "📍 **Bước 6 — Đánh giá:** ngưỡng, đường ROC / PR, chi phí.\n", "\n", "### 2.1 Mô hình không trả lời \"có / không\" — nó cho **điểm**\n", "\n", "SVM tính cho mỗi tin một **điểm** (`decision_function`): càng dương càng giống spam. `predict` chỉ là \"điểm > 0 thì chặn\". **0 là ngưỡng mặc định, không phải chân lý** — ta dời được.\n", "\n", "Muốn FN = 0 thì phải hạ ngưỡng xuống **dưới điểm của tin rác \"hiền\" nhất**. Câu hỏi: lúc đó bao nhiêu tin thật bị chặn theo?" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "diem_thi_goc = svm_goc.decision_function(X_thi_goc) # 📦 điểm của 1.293 tin phần thi\n", "y_thi_mang = y_thi.to_numpy()\n", "print(\"Điểm 3 tin đầu phần thi:\", np.round(diem_thi_goc[:3], 2), \"| nhãn thật:\", y_thi_mang[:3])\n", "\n", "nguong_fn0 = diem_thi_goc[y_thi_mang == 1].min() # điểm của tin rác thấp điểm nhất\n", "nguong_fp0 = diem_thi_goc[y_thi_mang == 0].max() # điểm của tin thật cao điểm nhất\n", "# ✏️ SỬA: thêm ngưỡng bạn muốn thử\n", "NGUONG_THU = [0.0, -0.5, nguong_fn0, nguong_fp0 + 1e-6]\n", "dong_nguong = []\n", "for nguong in NGUONG_THU:\n", " kq_n = cham_diem(y_thi_mang, (diem_thi_goc >= nguong).astype(int))\n", " dong_nguong.append({\"ngưỡng\": round(nguong, 3), \"chặn nhầm (FP)\": kq_n[\"FP\"], \"bỏ lọt (FN)\": kq_n[\"FN\"],\n", " \"FPR\": round(kq_n[\"FPR\"], 3), \"precision\": round(kq_n[\"precision\"], 3), \"recall\": round(kq_n[\"recall\"], 3)})\n", "print(pd.DataFrame(dong_nguong).to_string(index=False))\n", "# np.where(điều_kiện, a, b): lấy a ở chỗ điều kiện đúng, b ở chỗ sai -> ẩn nhóm kia đi rồi tìm vị trí min / max\n", "vi_tri_rac_hien_nhat = int(np.where(y_thi_mang == 1, diem_thi_goc, np.inf).argmin())\n", "vi_tri_that_giong_rac_nhat = int(np.where(y_thi_mang == 0, diem_thi_goc, -np.inf).argmax())\n", "print(f\"\\nTin RÁC điểm thấp nhất ({nguong_fn0:.2f}):\", repr(van_ban_thi.iloc[vi_tri_rac_hien_nhat]))\n", "print(f\"Tin THẬT điểm cao nhất ({nguong_fp0:.2f}):\", repr(van_ban_thi.iloc[vi_tri_that_giong_rac_nhat]))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "**Kết quả:** `Điểm 3 tin đầu phần thi: [-0.02 -0.48 -0.96] | nhãn thật: [1 0 0]` (tin đầu là tin rác điểm −0,02 — bị bỏ lọt **sát nút**), rồi:\n", "```\n", " ngưỡng chặn nhầm (FP) bỏ lọt (FN) FPR precision recall\n", " 0.000 3 20 0.003 0.979 0.877\n", " -0.500 28 8 0.025 0.847 0.951\n", " -1.062 425 0 0.376 0.277 1.000\n", " 0.238 0 26 0.000 1.000 0.840\n", "```\n", "`Tin RÁC điểm thấp nhất (-1.06): 'In The Simpsons Movie released in July 2007 name the band that died at the start of the film? A-Green Day, B-Blue Day, C-Red Day. (Send A, B or C)'` · `Tin THẬT điểm cao nhất (0.24): 'Glad to see your reply.'`\n", "\n", "📦 **`diem_thi_goc`** = 1.293 điểm · **`nguong_fn0`**, **`nguong_fp0`** = hai ngưỡng \"cực đoan\".\n", "\n", "👉 **Hiểu — đây là câu trả lời cho \"làm sao FN = 0\":**\n", "- Muốn **FN = 0**: hạ ngưỡng xuống −1,06 → **chặn nhầm 425 / 1.130 tin thật** (cứ 3 tin thật chặn hơn 1), precision còn 0,277. Không ai dùng nổi.\n", "- Thủ phạm là **một** tin rác trông như câu đố vui bình thường. Để bắt nó, ta phải chặn mọi tin thật \"hiền\" hơn nó.\n", "- Ngược lại, **FP = 0** (ngưỡng 0,24) thì bỏ lọt 26. Ngưỡng −0,5 là một điểm giữa: FP 28, FN 8.\n", "- Không có ngưỡng nào cho **cả hai** về 0. Chọn ngưỡng nào là chọn **lỗi nào chấp nhận được** → 2.3." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 2.2 Nhìn toàn bộ: hai đám điểm chồng lên nhau, đường ROC và đường PR\n", "\n", "| Biểu đồ | Trục ngang | Trục đứng | Cách đọc |\n", "|---|---|---|---|\n", "| **trái** — phân bố điểm | điểm SVM | số tin (thang log) | xanh = tin thật, đỏ = tin rác. **Vùng hai màu chồng nhau** là nơi mọi ngưỡng đều phải trả giá: kéo vạch sang trái bắt thêm đỏ nhưng chặn thêm xanh |\n", "| **giữa** — ROC | FPR (tỉ lệ chặn nhầm tin thật) | recall (tỉ lệ bắt được tin rác) | mỗi điểm trên đường = một ngưỡng. Càng sát **góc trên bên trái** càng tốt. **AUC** = diện tích dưới đường (1,0 = hoàn hảo, 0,5 = đoán bừa) |\n", "| **phải** — PR | recall | precision | khi lớp hiếm, đường này **trung thực hơn ROC**: nó cho thấy để recall lên 1,0 thì precision rơi đến đâu |\n", "\n", "Chấm đen = ngưỡng mặc định 0." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from sklearn.metrics import roc_curve, roc_auc_score, precision_recall_curve\n", "fpr_duong, tpr_duong, _ = roc_curve(y_thi_mang, diem_thi_goc) # _ = danh sách ngưỡng, không cần dùng\n", "pre_duong, rec_duong, _ = precision_recall_curve(y_thi_mang, diem_thi_goc)\n", "auc_goc = roc_auc_score(y_thi_mang, diem_thi_goc)\n", "\n", "hinh, (truc_a, truc_b, truc_c) = plt.subplots(1, 3, figsize=(15, 4))\n", "cac_moc = np.linspace(diem_thi_goc.min(), diem_thi_goc.max(), 50)\n", "truc_a.hist(diem_thi_goc[y_thi_mang == 0], bins=cac_moc, alpha=0.6, color=\"tab:blue\", label=\"tin thật (ham)\")\n", "truc_a.hist(diem_thi_goc[y_thi_mang == 1], bins=cac_moc, alpha=0.6, color=\"tab:red\", label=\"tin rác (spam)\")\n", "truc_a.axvline(0, color=\"k\", ls=\"--\", label=\"ngưỡng 0\")\n", "truc_a.axvline(nguong_fn0, color=\"tab:red\", ls=\":\", label=f\"ngưỡng FN=0 ({nguong_fn0:.2f})\")\n", "truc_a.set_yscale(\"log\"); truc_a.set_xlabel(\"điểm SVM\"); truc_a.set_title(\"Phân bố điểm trên phần thi\"); truc_a.legend(fontsize=8)\n", "truc_b.plot(fpr_duong, tpr_duong); truc_b.plot([0, 1], [0, 1], \"k:\", lw=0.8)\n", "truc_b.scatter([kq_goc[\"FPR\"]], [kq_goc[\"recall\"]], color=\"k\", zorder=3)\n", "truc_b.set_xlabel(\"FPR (chặn nhầm / tổng tin thật)\"); truc_b.set_ylabel(\"recall\"); truc_b.set_title(f\"ROC — AUC = {auc_goc:.4f}\")\n", "truc_c.plot(rec_duong, pre_duong); truc_c.scatter([kq_goc[\"recall\"]], [kq_goc[\"precision\"]], color=\"k\", zorder=3)\n", "truc_c.set_xlabel(\"recall\"); truc_c.set_ylabel(\"precision\"); truc_c.set_title(\"Precision – Recall\")\n", "plt.tight_layout(); plt.show()\n", "print(f\"AUC = {auc_goc:.4f}\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "**Kết quả:** 3 biểu đồ, `AUC = 0.9939`.\n", "- **Trái:** đám xanh (tin thật) tập trung quanh −1, đám đỏ (tin rác) quanh +1; giữa −1 và 0 có **một dải đỏ lẻ tẻ chồng lên xanh** — đó là 20 tin bỏ lọt và tin \"câu đố\" ở −1,06 (vạch chấm đỏ). Vạch chấm đỏ nằm **giữa đám xanh**: hạ ngưỡng tới đó là cắt vào hàng trăm tin thật.\n", "- **Giữa (ROC):** đường dựng gần thẳng đứng lên ~0,95 ngay khi FPR còn gần 0 → AUC 0,994, xếp hạng rất tốt. Chấm đen (ngưỡng 0) ở recall 0,88.\n", "- **Phải (PR):** precision giữ ~1,0 tới recall ~0,9 rồi **rơi thẳng đứng** khi recall tiến tới 1,0.\n", "\n", "👉 **Hiểu:** AUC 0,994 **không** có nghĩa là \"gần như không sai\": nó đo khả năng **xếp hạng**, còn số FP/FN thật phụ thuộc ngưỡng. Đoạn rơi thẳng ở đường PR chính là cái giá của vài phần trăm recall cuối cùng." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 2.3 Chọn ngưỡng theo **chi phí** — cách trả lời đúng cho câu \"FN bao nhiêu là được?\"\n", "\n", "Thay vì đòi FN = 0, hãy hỏi: **bỏ lọt 1 tin rác tốn gấp mấy lần chặn nhầm 1 tin thật?** (một vụ lừa đảo thành công vs. một khách hàng phải gọi lên hỏi). Rồi chọn ngưỡng làm **tổng chi phí** nhỏ nhất:\n", "\n", "> tổng chi phí = `CHI_PHI_FN` × số FN + `CHI_PHI_FP` × số FP\n", "\n", "**Quan trọng:** ngưỡng phải chọn trên **phần học** (dùng điểm kiểm định chéo — `cross_val_predict` cho mỗi tin phần học một điểm do mô hình *chưa thấy* tin đó tính ra), rồi mới **áp lên phần thi** để xem kết quả thật. Chọn ngưỡng ngay trên phần thi là \"nhìn đáp án\"." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from sklearn.model_selection import cross_val_predict\n", "diem_hoc_cv = cross_val_predict(ong_goc, van_ban_hoc, y_hoc, cv=chia_5_phan, method=\"decision_function\")\n", "# 📦 diem_hoc_cv = mỗi tin phần học 1 điểm, tính bởi mô hình học trên 4/5 phần còn lại\n", "y_hoc_mang = y_hoc.to_numpy()\n", "cac_nguong_thu = np.linspace(-2, 2, 401) # 401 ngưỡng từ -2 đến 2\n", "\n", "def chon_nguong(ti_le_chi_phi):\n", " # Trả về ngưỡng có tổng chi phí nhỏ nhất trên PHẦN HỌC. ti_le_chi_phi = chi phí 1 FN / chi phí 1 FP\n", " chi_phi = [ti_le_chi_phi * ((diem_hoc_cv < t) & (y_hoc_mang == 1)).sum() + ((diem_hoc_cv >= t) & (y_hoc_mang == 0)).sum()\n", " for t in cac_nguong_thu]\n", " return cac_nguong_thu[int(np.argmin(chi_phi))]\n", "\n", "# ✏️ SỬA: chi phí của bạn\n", "CHI_PHI_FN = 10\n", "CHI_PHI_FP = 1\n", "dong_chi_phi = []\n", "for ti_le in sorted({1, 10, 50, CHI_PHI_FN / CHI_PHI_FP}):\n", " nguong_tot = chon_nguong(ti_le)\n", " kq_cp = cham_diem(y_thi_mang, (diem_thi_goc >= nguong_tot).astype(int))\n", " dong_chi_phi.append({\"1 FN đắt bằng ... FP\": ti_le, \"ngưỡng (chọn trên phần học)\": round(nguong_tot, 2),\n", " \"PHẦN THI: chặn nhầm (FP)\": kq_cp[\"FP\"], \"bỏ lọt (FN)\": kq_cp[\"FN\"], \"F1\": round(kq_cp[\"F1\"], 3)})\n", "bang_chi_phi = pd.DataFrame(dong_chi_phi)\n", "bang_chi_phi" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "**Kết quả:**\n", "```\n", " 1 FN đắt bằng ... FP ngưỡng (chọn trên phần học) PHẦN THI: chặn nhầm (FP) bỏ lọt (FN) F1\n", " 1 -0.24 8 11 0.941\n", " 10 -0.48 27 9 0.895\n", " 50 -0.70 74 5 0.800\n", "```\n", "📦 **`diem_hoc_cv`** = điểm \"chưa nhìn thấy\" của 3.878 tin phần học · **`chon_nguong(ti_le)`** = hàm tìm ngưỡng rẻ nhất · **`bang_chi_phi`** = bảng trên (dùng lại ở báo cáo).\n", "\n", "👉 **Hiểu:**\n", "- Coi hai lỗi **ngang nhau** (1:1): ngưỡng −0,24 cho FP 8, FN 11 — **tổng lỗi 19**, ít hơn ngưỡng mặc định (3 + 20 = 23). Ngưỡng 0 của thư viện **không** tối ưu cho bài toán của bạn.\n", "- Bỏ lọt đắt gấp **10** lần: chấp nhận 27 chặn nhầm để còn 9 bỏ lọt. Gấp **50**: 74 chặn nhầm, còn 5 bỏ lọt.\n", "- Ngưỡng được chọn **chỉ bằng phần học**, rồi mới đem ra đề thi: các con số trên là kết quả **trung thực**.\n", "- Đổi `CHI_PHI_FN` thành con số của tổ chức bạn: đó là cách nói chuyện với sếp — \"muốn bớt X vụ lọt thì chấp nhận Y tin bị chặn nhầm mỗi ngày\"." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 2.4 Trả lời câu hỏi \"làm sao cho FN = 0?\"\n", "\n", "1. **Với một mô hình cố định, FN = 0 chỉ có một cách: hạ ngưỡng** — và cái giá đo được ở 2.1 là hàng trăm tin thật bị chặn. Đó không phải lỗi của mô hình, mà là **vùng chồng nhau** ở biểu đồ trái 2.2: có những tin rác viết giống hệt tin thật.\n", "2. **Muốn FN giảm mà FP không tăng** thì phải làm **mô hình tốt hơn** (kéo hai đám điểm ra xa nhau) → Phần 3–6.\n", "3. **Phần còn lại không bao giờ về 0** → không để máy quyết định một mình: vùng giữa chuyển cho **người xem** (Phần 7.2), và có lớp phòng thủ khác phía sau (chặn link độc, đào tạo người dùng, xác minh 2 bước khi chuyển tiền).\n", "4. Câu hỏi đúng không phải \"FN = 0?\" mà là **\"mỗi FN tốn bao nhiêu, mỗi FP tốn bao nhiêu?\"** → ngưỡng ở 2.3.\n", "\n", "---\n", "\n", "## Phần 3 — Đòn bẩy 1: thêm dữ liệu có giúp không?\n", "\n", "📍 **Bước 4 — Coding (cải thiện):** thêm dữ liệu.\n", "\n", "**Đường cong học** (learning curve): học trên 10%, 25%, 50%, 75%, 100% phần học, mỗi lần đo F1 kiểm định chéo. Kiếm thêm dữ liệu có nhãn rất **tốn kém** (phải có người gắn nhãn) — hãy hỏi đường cong trước khi đi kiếm.\n", "\n", "| Đường | Nghĩa |\n", "|---|---|\n", "| **cam** — điểm trên chính phần đã học | mô hình \"tự chấm bài đã học\" |\n", "| **xanh** — điểm kiểm định chéo | điểm trên dữ liệu mới, thứ ta quan tâm |\n", "| Đường xanh **còn đang đi lên** ở bên phải | thêm dữ liệu **còn** giúp |\n", "| Đường xanh **đã nằm ngang** | thêm dữ liệu **gần như không** giúp → cần đặc trưng hoặc mô hình khác |\n", "| Khoảng cách cam – xanh lớn | mô hình học thuộc (quá khớp) |" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from sklearn.model_selection import learning_curve\n", "# ✏️ SỬA: các tỉ lệ dữ liệu học thử\n", "TI_LE_DU_LIEU = [0.1, 0.25, 0.5, 0.75, 1.0]\n", "so_tin_hoc, f1_tu_cham, f1_cv_lc = learning_curve(ong_goc, van_ban_hoc, y_hoc, cv=chia_5_phan, scoring=\"f1\",\n", " train_sizes=TI_LE_DU_LIEU)\n", "# 📦 so_tin_hoc = số tin học mỗi mức · f1_tu_cham / f1_cv_lc = bảng (số mức × 5 phần) điểm F1\n", "duong_cong = pd.DataFrame({\"số tin học\": so_tin_hoc, \"F1 tự chấm\": f1_tu_cham.mean(axis=1).round(3),\n", " \"F1 kiểm định chéo\": f1_cv_lc.mean(axis=1).round(3), \"± (độ lệch)\": f1_cv_lc.std(axis=1).round(3)})\n", "print(duong_cong.to_string(index=False))\n", "plt.figure(figsize=(7, 4))\n", "plt.plot(so_tin_hoc, f1_tu_cham.mean(axis=1), \"o-\", color=\"tab:orange\", label=\"tự chấm bài đã học\")\n", "plt.errorbar(so_tin_hoc, f1_cv_lc.mean(axis=1), yerr=f1_cv_lc.std(axis=1), fmt=\"o-\", color=\"tab:blue\", label=\"kiểm định chéo\")\n", "plt.xlabel(\"số tin dùng để học\"); plt.ylabel(\"F1\"); plt.title(\"Đường cong học — SVM trên từ đơn\"); plt.legend(); plt.grid(alpha=0.3); plt.show()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "**Kết quả:**\n", "```\n", " số tin học F1 tự chấm F1 kiểm định chéo ± (độ lệch)\n", " 310 1.000 0.812 0.043\n", " 775 0.999 0.901 0.018\n", " 1551 0.998 0.915 0.013\n", " 2326 0.999 0.930 0.012\n", " 3102 0.998 0.930 0.021\n", "```\n", "và biểu đồ: đường cam nằm sát 1,0 suốt; đường xanh đi lên nhanh từ 0,81 đến 0,93 rồi **nằm ngang** ở hai mức cuối.\n", "\n", "📦 **`so_tin_hoc`**, **`f1_tu_cham`**, **`f1_cv_lc`** = kết quả `learning_curve` · **`duong_cong`** = bảng trên.\n", "\n", "👉 **Hiểu:**\n", "- Từ 310 lên 2.326 tin, F1 tăng 0,12 — lúc ít dữ liệu, **thêm dữ liệu rất lời**.\n", "- Từ 2.326 lên 3.102 tin: **0,930 → 0,930**. Đi gắn nhãn thêm vài nghìn tin cho **cùng** mô hình này gần như không được gì.\n", "- Đường cam ~1,0: mô hình thuộc lòng phần học (khoảng cách cam–xanh ~0,07). Chẩn đoán: **mô hình đã \"nhìn\" hết những gì từ đơn cho nó thấy** → cần cho nó nhìn thứ khác (Phần 4)." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "---\n", "\n", "## Phần 4 — Đòn bẩy 2: thêm đặc trưng\n", "\n", "📍 **Bước 4 — Coding (cải thiện):** thêm đặc trưng; 4.3 là **lưu ý** (bước 5): tốt hơn thật hay may?\n", "\n", "### 4.1 Máy đang \"nhìn\" gì? Từ đơn, cặp từ, cụm ký tự\n", "\n", "Mô hình gốc chỉ thấy **từ đơn**. Nhưng người đọc tin rác nhận ra nó nhờ cả những thứ khác: số điện thoại dài, ký hiệu `£`, chữ viết hoa, từ viết lách luật (`FR33`, `txt`), link. Ba cách \"nhìn\" của `TfidfVectorizer`:\n", "\n", "| Cách | Tham số | Ví dụ với `\"WIN £1000 cash\"` |\n", "|---|---|---|\n", "| từ đơn | mặc định | `win`, `1000`, `cash` |\n", "| từ đơn + cặp từ | `ngram_range=(1, 2)` | thêm `win 1000`, `1000 cash` |\n", "| cụm ký tự 2–5 trong từ | `analyzer=\"char_wb\", ngram_range=(2, 5)` | ` w`, `wi`, `win`, `£1`, `£10`, `000 `... — bắt được cả ký hiệu và chữ viết sai |\n", "\n", "Ngoài ra ta tự tính thêm **5 số đo** cho mỗi tin: độ dài, số chữ số, có link không, có dãy ≥ 5 chữ số không (số điện thoại / mã), tỉ lệ chữ hoa." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import re\n", "vi_du = \"WIN £1000 cash! Call 09061701461 now\"\n", "print(\"từ đơn :\", TfidfVectorizer().build_analyzer()(vi_du))\n", "print(\"từ đơn + cặp từ :\", TfidfVectorizer(ngram_range=(1, 2)).build_analyzer()(vi_du)[-5:], \"... (5 phần tử cuối)\")\n", "print(\"cụm ký tự 2–5 :\", TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 5)).build_analyzer()(vi_du)[:12], \"... (12 phần tử đầu)\")\n", "\n", "def dac_trung_so(chuoi_tin):\n", " # Nhận một cột tin nhắn (pandas Series), trả về mảng (số tin × 5) con số tự tính\n", " chuoi_tin = pd.Series(chuoi_tin)\n", " bang_so = pd.DataFrame({\n", " \"do_dai\": chuoi_tin.str.len(), # số ký tự\n", " \"so_chu_so\": chuoi_tin.str.count(r\"\\d\"), # \\d = một chữ số\n", " \"co_link\": chuoi_tin.str.contains(r\"http|www\\.|\\.com\", case=False).astype(int),\n", " \"co_day_5_so\": chuoi_tin.str.contains(r\"\\d{5,}\").astype(int), # ≥ 5 chữ số liền nhau\n", " \"ti_le_hoa\": chuoi_tin.str.count(r\"[A-Z]\") / chuoi_tin.str.len().clip(lower=1), # clip: tránh chia cho 0\n", " })\n", " return bang_so.to_numpy(dtype=float)\n", "\n", "TEN_SO_DO = [\"do_dai\", \"so_chu_so\", \"co_link\", \"co_day_5_so\", \"ti_le_hoa\"]\n", "mau_2_tin = pd.Series([\"Ok lar... Joking wif u oni...\", vi_du])\n", "print(\"\\n5 số đo của 1 tin thật và 1 tin rác:\")\n", "print(pd.DataFrame(dac_trung_so(mau_2_tin), columns=TEN_SO_DO, index=mau_2_tin).round(2).to_string())" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "**Kết quả:**\n", "```\n", "từ đơn : ['win', '1000', 'cash', 'call', '09061701461', 'now']\n", "từ đơn + cặp từ : ['win 1000', '1000 cash', 'cash call', 'call 09061701461', '09061701461 now'] ... (5 phần tử cuối)\n", "cụm ký tự 2–5 : [' w', 'wi', 'in', 'n ', ' wi', 'win', 'in ', ' win', 'win ', ' win ', ' £', '£1'] ... (12 phần tử đầu)\n", "```\n", "và bảng 5 số đo: tin thật `do_dai 29, so_chu_so 0, co_link 0, co_day_5_so 0, ti_le_hoa 0.07`; tin rác `36, 15, 0, 1, 0.11`.\n", "\n", "📦 **`dac_trung_so(chuoi_tin)`** = hàm trả về 5 số đo cho mỗi tin · **`TEN_SO_DO`** = tên 5 cột.\n", "\n", "👉 **Hiểu:** với từ đơn, ký hiệu **`£` biến mất** (bộ tách từ mặc định bỏ ký tự đặc biệt) và số điện thoại chỉ là \"một từ lạ\" xuất hiện một lần. Cụm ký tự giữ lại `£1`, `£10`, và các mảnh như `0906` dùng chung giữa **nhiều** số điện thoại tổng đài trả phí khác nhau." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 4.2 So sánh 5 cách \"nhìn\" — cùng một mô hình SVM\n", "\n", "`FeatureUnion` ghép nhiều cách biến đổi **cạnh nhau** (đặt các cột số liền nhau thành một bảng lớn). 5 số đo tự tính có thang rất khác nhau (độ dài ~100, tỉ lệ ~0,1) nên đi qua `StandardScaler` (đưa về cùng thang) trước khi ghép.\n", "\n", "Mỗi cách đo **hai lần**: kiểm định chéo trên phần học (trung bình ± độ lệch) và đề thi thật. **Cách đọc:** chỉ tin là \"tốt hơn thật\" khi chênh lệch **lớn hơn** cỡ độ lệch ở 1.2." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from sklearn.pipeline import FeatureUnion\n", "from sklearn.preprocessing import FunctionTransformer, StandardScaler\n", "\n", "def tao_svm():\n", " return LinearSVC(random_state=RANDOM_STATE)\n", "\n", "so_do_chuan_hoa = make_pipeline(FunctionTransformer(dac_trung_so), StandardScaler()) # 5 số đo -> cùng thang\n", "cac_cach_nhin = {\n", " \"1. từ đơn (như Lab 4)\": make_pipeline(TfidfVectorizer(), tao_svm()),\n", " \"2. từ đơn + cặp từ\": make_pipeline(TfidfVectorizer(ngram_range=(1, 2)), tao_svm()),\n", " \"3. cụm ký tự 2-5\": make_pipeline(TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 5)), tao_svm()),\n", " \"4. từ + ký tự\": make_pipeline(FeatureUnion([(\"tu\", TfidfVectorizer()),\n", " (\"ky_tu\", TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 5)))]), tao_svm()),\n", " \"5. từ + ký tự + 5 số đo\": make_pipeline(FeatureUnion([(\"tu\", TfidfVectorizer()),\n", " (\"ky_tu\", TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 5))),\n", " (\"so_do\", so_do_chuan_hoa)]), tao_svm()),\n", "}\n", "dong_dac_trung = []\n", "f1_tung_phan = {} # 📦 tên cách nhìn -> mảng 5 điểm F1 (cùng 5 phần cho mọi cách)\n", "for ten_cach, ong_cach in cac_cach_nhin.items():\n", " f1_cv_cach = cross_val_score(ong_cach, van_ban_hoc, y_hoc, cv=chia_5_phan, scoring=\"f1\")\n", " f1_tung_phan[ten_cach] = f1_cv_cach\n", " kq_cach = cham_diem(y_thi, ong_cach.fit(van_ban_hoc, y_hoc).predict(van_ban_thi))\n", " dong_dac_trung.append({\"cách nhìn\": ten_cach, \"F1 kiểm định chéo\": f1_cv_cach.mean(), \"±\": f1_cv_cach.std(),\n", " \"F1 phần thi\": kq_cach[\"F1\"], \"chặn nhầm (FP)\": kq_cach[\"FP\"], \"bỏ lọt (FN)\": kq_cach[\"FN\"]})\n", "so_sanh_dac_trung = pd.DataFrame(dong_dac_trung).set_index(\"cách nhìn\")\n", "# 📦 so_sanh_dac_trung = bảng 5 cách nhìn × các chỉ số\n", "so_sanh_dac_trung[\"F1 kiểm định chéo\"].plot(kind=\"barh\", xerr=so_sanh_dac_trung[\"±\"], figsize=(8, 3), xlim=(0.85, 1.0),\n", " title=\"F1 kiểm định chéo (vạch đen = ± độ lệch)\")\n", "plt.show()\n", "print(so_sanh_dac_trung.round(3).to_string())" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "**Kết quả:** biểu đồ thanh và bảng:\n", "```\n", " F1 kiểm định chéo ± F1 phần thi chặn nhầm (FP) bỏ lọt (FN)\n", "1. từ đơn (như Lab 4) 0.930 0.021 0.926 3 20\n", "2. từ đơn + cặp từ 0.934 0.016 0.936 4 16\n", "3. cụm ký tự 2-5 0.953 0.024 0.966 2 9\n", "4. từ + ký tự 0.955 0.020 0.956 2 12\n", "5. từ + ký tự + 5 số đo 0.953 0.024 0.956 2 12\n", "```\n", "📦 **`cac_cach_nhin`** = 5 ống · **`so_sanh_dac_trung`** = bảng trên · **`f1_tung_phan`** = 5 điểm từng phần của mỗi cách (dùng ở 4.3).\n", "\n", "👉 **Hiểu:**\n", "- **Cụm ký tự** là đòn bẩy lớn: trên đề thi, bỏ lọt **20 → 9** và chặn nhầm 3 → 2 — **cả hai lỗi cùng giảm**, điều mà dời ngưỡng ở Phần 2 không bao giờ làm được.\n", "- Cặp từ chỉ nhích nhẹ (0,930 → 0,934, nhỏ hơn cỡ ±).\n", "- **5 số đo tự tính không thêm gì** (cách 5 = cách 4): cụm ký tự đã \"thấy\" chữ số, `£`, link rồi. Đặc trưng mới chỉ có ích khi mang **thông tin mới**.\n", "- Vạch ± ở biểu đồ vẫn chồng lên nhau → kiểm tra kỹ hơn ở 4.3." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 4.3 \"Tốt hơn thật\" hay \"may\"? So **theo từng cặp**\n", "\n", "Ở 4.2, vạch ± của các cách nhìn **chồng lên nhau** — nhìn thế thì chưa kết luận được. Nhưng mọi cách nhìn được chấm trên **cùng 5 phần** dữ liệu, nên so sánh đúng hơn là **từng phần một**: ở phần 1, cách mới hơn cách cũ bao nhiêu? phần 2? ... Nếu cách mới thắng ở **cả 5 phần**, khó mà là may." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "cach_moc = \"1. từ đơn (như Lab 4)\"\n", "for ten_cach, f1_cach in f1_tung_phan.items():\n", " if ten_cach == cach_moc:\n", " continue\n", " chenh = f1_cach - f1_tung_phan[cach_moc] # 📦 mảng 5 hiệu số, phần nào với phần đó\n", " print(f\"{ten_cach:26s} hơn từ đơn ở từng phần: {np.round(chenh, 3)} -> thắng {int((chenh > 0).sum())}/5 phần\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "**Kết quả:**\n", "```\n", "2. từ đơn + cặp từ hơn từ đơn ở từng phần: [ 0.001 0.012 0.006 0.006 -0.005] -> thắng 4/5 phần\n", "3. cụm ký tự 2-5 hơn từ đơn ở từng phần: [0.016 0.059 0.01 0.006 0.021] -> thắng 5/5 phần\n", "4. từ + ký tự hơn từ đơn ở từng phần: [0.016 0.054 0.022 0.011 0.021] -> thắng 5/5 phần\n", "5. từ + ký tự + 5 số đo hơn từ đơn ở từng phần: [0.027 0.049 0. 0.017 0.021] -> thắng 4/5 phần\n", "```\n", "👉 **Hiểu:** cụm ký tự thắng **cả 5 phần**, có phần hơn tới 0,059 → cải thiện **thật**. Cặp từ thua 1 phần và chỉ hơn ~0,005 → **chưa đủ bằng chứng**. Cách 5 hòa đúng 0,000 ở phần 3 — thêm 5 số đo không đem lại gì so với cách 4. Đây là cách so sánh hai mô hình mà nhóm nghiên cứu hay dùng: cùng phần, so từng cặp." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "---\n", "\n", "## Phần 5 — Đòn bẩy 3: mô hình khác\n", "\n", "📍 **Bước 3 + 4 — Chọn mô hình và coding:** 7 mô hình trên cùng dữ liệu.\n", "\n", "### 5.1 Bảy mô hình trên cùng một dữ liệu (từ đơn TF-IDF)\n", "\n", "Mỗi mô hình được đo: F1 kiểm định chéo, F1 + FP + FN trên phần thi, **AUC** (khả năng xếp hạng — không phụ thuộc ngưỡng), **thời gian học** 5 lần kiểm định chéo, và **giải thích được không** (con người có đọc được vì sao nó chặn một tin không).\n", "\n", "| Mô hình | Ý tưởng một dòng |\n", "|---|---|\n", "| Naive Bayes | đếm xác suất từng từ xuất hiện trong spam / ham, nhân lại |\n", "| Logistic Regression | cộng điểm từng từ, ra xác suất; `class_weight=\"balanced\"` = phạt lỗi trên lớp hiếm nặng hơn |\n", "| SVM tuyến tính | ranh giới thẳng có vùng đệm rộng nhất |\n", "| Cây quyết định | chuỗi câu hỏi có / không |\n", "| Rừng ngẫu nhiên | 300 cây bỏ phiếu |\n", "| XGBoost | 300 cây nhỏ nối tiếp, cây sau sửa lỗi cây trước |\n", "| Mạng nơ-ron (MLP) | 1 lớp ẩn 64 nơ-ron |" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from sklearn.model_selection import cross_validate\n", "from sklearn.metrics import roc_auc_score\n", "from sklearn.naive_bayes import MultinomialNB\n", "from sklearn.linear_model import LogisticRegression\n", "from sklearn.tree import DecisionTreeClassifier\n", "from sklearn.ensemble import RandomForestClassifier\n", "from sklearn.neural_network import MLPClassifier\n", "from xgboost import XGBClassifier\n", "\n", "# ✏️ SỬA: False để bỏ qua 3 mô hình chậm (rừng, XGBoost, mạng nơ-ron) nếu máy đang đông người\n", "CHAY_MO_HINH_CHAM = True\n", "cac_mo_hinh = {\n", " \"Naive Bayes\": (MultinomialNB(), \"dễ: xác suất từng từ\"),\n", " \"Logistic Regression (balanced)\": (LogisticRegression(max_iter=1000, class_weight=\"balanced\"), \"dễ: trọng số từng từ\"),\n", " \"SVM tuyến tính\": (tao_svm(), \"dễ: trọng số từng từ\"),\n", " \"Cây quyết định\": (DecisionTreeClassifier(random_state=RANDOM_STATE), \"dễ nếu cây nông; cây sâu thì khó\"),\n", "}\n", "if CHAY_MO_HINH_CHAM:\n", " cac_mo_hinh.update({\n", " \"Rừng ngẫu nhiên (300 cây)\": (RandomForestClassifier(n_estimators=300, n_jobs=2, random_state=RANDOM_STATE), \"khó: cần SHAP\"),\n", " \"XGBoost (300 cây)\": (XGBClassifier(n_estimators=300, n_jobs=2, random_state=RANDOM_STATE, eval_metric=\"logloss\"), \"khó: cần SHAP\"),\n", " \"Mạng nơ-ron MLP (64)\": (MLPClassifier(hidden_layer_sizes=(64,), max_iter=300, random_state=RANDOM_STATE), \"rất khó\"),\n", " })\n", "\n", "def diem_lien_tuc(mo_hinh, X):\n", " # Điểm \"giống spam\" để tính AUC: mô hình nào có decision_function thì dùng, không thì xác suất lớp 1\n", " return mo_hinh.decision_function(X) if hasattr(mo_hinh, \"decision_function\") else mo_hinh.predict_proba(X)[:, 1]\n", "\n", "dong_mo_hinh = []\n", "for ten_mh, (mo_hinh, giai_thich) in cac_mo_hinh.items():\n", " ong_mh = make_pipeline(TfidfVectorizer(), mo_hinh)\n", " do_cv = cross_validate(ong_mh, van_ban_hoc, y_hoc, cv=chia_5_phan, scoring=\"f1\") # dict: test_score, fit_time...\n", " ong_mh.fit(van_ban_hoc, y_hoc)\n", " kq_mh = cham_diem(y_thi, ong_mh.predict(van_ban_thi))\n", " dong_mo_hinh.append({\"mô hình\": ten_mh, \"F1 kiểm định chéo\": do_cv[\"test_score\"].mean(), \"±\": do_cv[\"test_score\"].std(),\n", " \"F1 phần thi\": kq_mh[\"F1\"], \"FP\": kq_mh[\"FP\"], \"FN\": kq_mh[\"FN\"],\n", " \"AUC\": roc_auc_score(y_thi, diem_lien_tuc(ong_mh, van_ban_thi)),\n", " \"giây học (5 lần)\": do_cv[\"fit_time\"].sum(), \"giải thích được?\": giai_thich})\n", " print(f\"xong {ten_mh}\")\n", "so_sanh_mo_hinh = pd.DataFrame(dong_mo_hinh).set_index(\"mô hình\")\n", "# 📦 so_sanh_mo_hinh = bảng mô hình × chỉ số\n", "so_sanh_mo_hinh.round(3)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "**Kết quả:** (in `xong ...` sau mỗi mô hình), rồi:\n", "```\n", " F1 kiểm định chéo ± F1 phần thi FP FN AUC giây học (5 lần)\n", "Naive Bayes 0.737 0.046 0.741 0 67 0.983 0.297\n", "Logistic Regression (balanced) 0.911 0.019 0.915 16 12 0.989 0.561\n", "SVM tuyến tính 0.930 0.021 0.926 3 20 0.994 0.283\n", "Cây quyết định 0.798 0.041 0.854 14 31 0.899 1.148\n", "Rừng ngẫu nhiên (300 cây) 0.875 0.022 0.881 1 34 0.994 8.990\n", "XGBoost (300 cây) 0.850 0.047 0.872 4 34 0.966 17.512\n", "Mạng nơ-ron MLP (64) 0.926 0.025 0.923 4 20 0.993 34.712\n", "```\n", "📦 **`cac_mo_hinh`** = tên → (mô hình, giải thích được?) · **`so_sanh_mo_hinh`** = bảng trên · **`diem_lien_tuc(mo_hinh, X)`** = điểm để tính AUC.\n", "\n", "👉 **Hiểu:**\n", "- Trên **văn bản**, mô hình tuyến tính (SVM, Logistic) **thắng** các mô hình cây: cây phải hỏi từng từ một trong hàng nghìn cột thưa. (Trên bảng số ít cột của honeypot, rừng lại rất tốt — Lab 3: F1 0,876.) **Không có mô hình giỏi nhất cho mọi bài**.\n", "- Mạng nơ-ron **ngang** SVM (0,926 vs 0,930) nhưng chậm hơn **~120 lần** (34,7 giây so với 0,28 giây) và gần như không giải thích được.\n", "- **Rừng ngẫu nhiên**: AUC 0,994 bằng SVM (xếp hạng rất tốt) nhưng F1 thấp vì ngưỡng 0,5 quá cao với nó (FN 34, FP 1) → dời ngưỡng như Phần 2 là kéo lại được: đo thử, hạ ngưỡng xác suất từ 0,5 xuống **0,25** cho rừng **FP 3, FN 13, F1 0,949** — hơn cả SVM ở ngưỡng mặc định (ngưỡng này dò trên đề thi chỉ để minh họa; làm thật phải chọn trên phần học như 2.3). **AUC cao + F1 thấp = vấn đề ngưỡng**, không phải mô hình dở.\n", "- XGBoost (0,850 ± 0,047) cũng thua SVM và dao động mạnh nhất nhóm cây: mô hình \"nổi tiếng thắng các cuộc thi\" thắng trên **bảng số**, không phải trên văn bản thưa.\n", "- Naive Bayes: FP 0 nhưng bỏ lọt 67 — cực \"nhút nhát\".\n", "- So với Phần 4: đổi cách nhìn (0,930 → 0,955) **lời hơn** đổi mô hình (không mô hình nào vượt 0,930 trên từ đơn)." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 5.2 Đổi chính xác lấy tốc độ?\n", "\n", "| Trục | Cách đọc |\n", "|---|---|\n", "| ngang — giây học 5 lần (thang log) | càng trái càng nhanh (rẻ khi phải học lại hằng tuần) |\n", "| đứng — F1 kiểm định chéo | càng cao càng tốt |\n", "| vạch ngang | ± độ lệch: hai mô hình có vạch chồng nhau thì **chưa chắc** cái nào hơn |" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "plt.figure(figsize=(8, 4.5))\n", "for ten_mh, dong_mh in so_sanh_mo_hinh.iterrows(): # iterrows: lặp từng dòng (tên, dữ liệu)\n", " plt.errorbar(dong_mh[\"giây học (5 lần)\"], dong_mh[\"F1 kiểm định chéo\"], yerr=dong_mh[\"±\"], fmt=\"o\", capsize=3)\n", " plt.annotate(ten_mh, (dong_mh[\"giây học (5 lần)\"], dong_mh[\"F1 kiểm định chéo\"]), fontsize=8,\n", " xytext=(5, 3), textcoords=\"offset points\")\n", "plt.xscale(\"log\"); plt.margins(x=0.3); plt.xlabel(\"giây học (5 lần kiểm định chéo, thang log)\"); plt.ylabel(\"F1 kiểm định chéo\")\n", "plt.title(\"Chính xác và tốc độ — cùng dữ liệu từ đơn TF-IDF\"); plt.grid(alpha=0.3); plt.show()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "**Kết quả:** biểu đồ phân tán: SVM và Logistic ở **góc trên bên trái** (nhanh và tốt); mạng nơ-ron ở **góc trên bên phải** (tốt ngang nhưng chậm nhất); rừng (~9 giây) và XGBoost (~18 giây) ở giữa nhưng thấp hơn SVM; cây quyết định và Naive Bayes thấp nhất. Vạch ± của Naive Bayes, XGBoost và cây dài nhất (kém ổn định).\n", "\n", "👉 **Hiểu:** khi hai mô hình ngang điểm, chọn cái **nhanh hơn và dễ giải thích hơn** — mô hình phải học lại khi dữ liệu trôi, và đội SOC phải trả lời được \"vì sao tin này bị chặn\"." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "---\n", "\n", "## Phần 6 — Đòn bẩy 4: tinh chỉnh tham số\n", "\n", "📍 **Bước 4 + 5 — Coding và lưu ý:** tinh chỉnh **chỉ** bằng kiểm định chéo trên phần học, không bao giờ trên đề thi.\n", "\n", "### 6.1 GridSearchCV: thử mọi tổ hợp, chấm bằng kiểm định chéo\n", "\n", "Lấy cách nhìn tốt ở Phần 4 (cụm ký tự 2–5) + SVM, rồi vặn 2 núm:\n", "\n", "| Núm | Ý nghĩa | Giá trị thử |\n", "|---|---|---|\n", "| `C` | **nhỏ** = chấp nhận sai vài tin học để ranh giới đơn giản (ít học thuộc); **lớn** = cố khớp từng tin học | 0,1 · 0,3 · 1 · 3 · 10 |\n", "| `class_weight` | `None` = mọi lỗi như nhau; `\"balanced\"` = lỗi trên lớp hiếm (spam) phạt nặng hơn → giảm FN, tăng FP | None · balanced |\n", "\n", "`GridSearchCV` thử **10 tổ hợp × 5 phần = 50 lần học**, chỉ trên **phần học**. Tên núm viết `linearsvc__C`: tên bước trong ống (`make_pipeline` tự đặt = tên lớp viết thường) + 2 dấu gạch dưới + tên núm.\n", "\n", "**Luật vàng:** phần thi chỉ được dùng **một lần**, sau khi đã chọn xong. Nếu chọn núm nhìn vào phần thi, điểm thi không còn là điểm \"dữ liệu mới\" nữa." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from sklearn.model_selection import GridSearchCV\n", "ong_ky_tu = make_pipeline(TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 5)), tao_svm())\n", "# ✏️ SỬA: lưới giá trị thử (thêm giá trị = lâu hơn)\n", "LUOI_NUM = {\"linearsvc__C\": [0.1, 0.3, 1, 3, 10], \"linearsvc__class_weight\": [None, \"balanced\"]}\n", "bat_dau = time.time()\n", "tim_num = GridSearchCV(ong_ky_tu, LUOI_NUM, cv=chia_5_phan, scoring=\"f1\", n_jobs=2).fit(van_ban_hoc, y_hoc)\n", "print(f\"Thử {len(tim_num.cv_results_['params'])} tổ hợp trong {time.time() - bat_dau:.0f} giây\")\n", "bang_luoi = pd.DataFrame(tim_num.cv_results_)[[\"param_linearsvc__C\", \"param_linearsvc__class_weight\",\n", " \"mean_test_score\", \"std_test_score\"]]\n", "bang_luoi.columns = [\"C\", \"class_weight\", \"F1 kiểm định chéo\", \"±\"]\n", "print(bang_luoi.sort_values(\"F1 kiểm định chéo\", ascending=False).round(3).to_string(index=False))\n", "print(\"\\nTổ hợp tốt nhất:\", tim_num.best_params_, f\"| F1 kiểm định chéo {tim_num.best_score_:.3f}\")\n", "mo_hinh_cuoi = tim_num.best_estimator_ # 📦 ống tốt nhất, đã học lại trên TOÀN BỘ phần học\n", "kq_cuoi = cham_diem(y_thi, mo_hinh_cuoi.predict(van_ban_thi))\n", "print(f\"PHẦN THI (dùng 1 lần): F1 {kq_cuoi['F1']:.3f} | chặn nhầm {kq_cuoi['FP']} | bỏ lọt {kq_cuoi['FN']}\"\n", " f\" (gốc Lab 4: F1 {kq_goc['F1']:.3f} | {kq_goc['FP']} | {kq_goc['FN']})\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "**Kết quả:** `Thử 10 tổ hợp trong 27 giây`, bảng xếp hạng (đầu: `C 0.3, balanced, 0.967 ± 0.016`; cuối: `C 0.1, None, 0.892`), `Tổ hợp tốt nhất: {'linearsvc__C': 0.3, 'linearsvc__class_weight': 'balanced'} | F1 kiểm định chéo 0.967` · `PHẦN THI (dùng 1 lần): F1 0.966 | chặn nhầm 3 | bỏ lọt 8 (gốc Lab 4: F1 0.926 | 3 | 20)`\n", "\n", "📦 **`tim_num`** = kết quả tìm kiếm (`best_params_`, `cv_results_`) · **`bang_luoi`** = bảng 10 tổ hợp · **`mo_hinh_cuoi`** = ống tốt nhất · **`kq_cuoi`** = chỉ số trên phần thi.\n", "\n", "👉 **Hiểu:**\n", "- **`class_weight=\"balanced\"`** đứng đầu: phạt nặng lỗi trên lớp hiếm kéo bỏ lọt xuống mà không tăng chặn nhầm.\n", "- Chọn sai núm thì tụt mạnh: `C = 0.1` không cân bằng chỉ còn 0,892 — **tinh chỉnh quan trọng**, và mặc định của thư viện không phải lúc nào cũng tốt.\n", "- Kết quả cuối so với Lab 4: **chặn nhầm giữ 3, bỏ lọt 20 → 8** (giảm 60%), không đổi thuật toán, chỉ đổi **cách nhìn** + **núm**.\n", "- Phần thi chỉ được nhìn **sau khi** đã chọn xong, nên 0,966 là con số trung thực." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "---\n", "\n", "## Phần 7 — Phân tích lỗi còn lại\n", "\n", "📍 **Bước 6 — Đánh giá:** đọc từng lỗi còn lại.\n", "\n", "### 7.1 Đọc từng tin máy vẫn sai\n", "\n", "Sau mọi đòn bẩy vẫn còn lỗi. **Đọc chúng** là bước quan trọng nhất: mỗi lỗi thuộc một trong ba loại —\n", "(a) **sửa được** bằng đặc trưng / dữ liệu mới, (b) **nhãn đáng ngờ** (người gắn nhãn cũng có thể cãi nhau), (c) **mơ hồ thật sự** — chính con người đọc cũng không chắc → phải chấp nhận." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "diem_thi_cuoi = mo_hinh_cuoi.decision_function(van_ban_thi) # 📦 điểm mô hình cuối cho 1.293 tin thi\n", "du_doan_cuoi = mo_hinh_cuoi.predict(van_ban_thi)\n", "bang_loi = pd.DataFrame({\"điểm\": diem_thi_cuoi.round(2), \"nhãn thật\": y_thi_mang, \"tin nhắn\": van_ban_thi.to_numpy()})\n", "print(f\"BỎ LỌT (spam bị cho qua) — {kq_cuoi['FN']} tin, xếp từ điểm cao xuống:\")\n", "print(bang_loi[(y_thi_mang == 1) & (du_doan_cuoi == 0)].sort_values(\"điểm\", ascending=False).to_string(index=False))\n", "print(f\"\\nCHẶN NHẦM (tin thật bị chặn) — {kq_cuoi['FP']} tin:\")\n", "print(bang_loi[(y_thi_mang == 0) & (du_doan_cuoi == 1)].to_string(index=False))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "**Kết quả:** 8 tin bỏ lọt (điểm từ −0,04 đến −0,66), ví dụ:\n", "- `-0.04` `Babe: U want me dont u baby! Im nasty ... Txt XXX SLO(4msgs)` — sát ngưỡng\n", "- `-0.16` `ringtoneking 84484` · `-0.28` `RCT' THNQ Adrian for U text. Rgds Vatian` — quá ngắn\n", "- `-0.17` `Burger King - Wanna play footy at a top stadium? ...` · `-0.20` `Latest News! Police station toilet stolen, cops have nothing to go on!`\n", "- `-0.50` câu đố Simpsons (tin ở 2.1) · `-0.65` `Money i have won wining number 946 wot do i do next`\n", "\n", "và 3 tin chặn nhầm: `Your daily text from me – a favour this time` (0.18), `Glad to see your reply.` (0.32), `I (Career Tel) have added u as a contact on INDYAROCKS.COM to send FREE SMS. To remove from phonebook - sms NO to ...` (0.37).\n", "\n", "📦 **`diem_thi_cuoi`**, **`du_doan_cuoi`** = điểm và dự đoán của mô hình cuối · **`bang_loi`** = bảng điểm + nhãn + tin.\n", "\n", "👉 **Hiểu — phân loại từng lỗi:**\n", "- **Sửa được (có thể):** tin sát ngưỡng (−0,04) → dời ngưỡng / vùng giữa; tin rất ngắn có số tổng đài (`ringtoneking 84484`) → thêm đặc trưng \"tin cực ngắn có số 5 chữ số\", hoặc **tín hiệu ngoài văn bản** (số gửi là tổng đài).\n", "- **Nhãn đáng ngờ:** `Latest News! Police station toilet stolen...` là một câu đùa, nội dung **không có** dấu hiệu lừa đảo nào — nhãn spam chỉ hợp lý nếu biết nó đến từ một dịch vụ gửi tin hàng loạt, thông tin đó **không nằm trong tin**; `Your daily text from me – a favour this time` gắn ham nhưng đọc như tin dịch vụ; tin `INDYAROCKS.COM ... FREE SMS ... sms NO` gắn ham nhưng giống hệt tin rác. **Chính người gắn nhãn cũng có thể cãi nhau.**\n", "- **Mơ hồ thật sự:** `Money i have won wining number 946 wot do i do next` có thể là người thật khoe trúng xổ số; `Glad to see your reply.` là tin thật nhưng giống câu mở đầu lừa đảo.\n", "- Kết luận: phần lỗi còn lại **không** gỡ được bằng mô hình tốt hơn — cần **thông tin khác** (người gửi, lịch sử liên lạc) hoặc **người xem**." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 7.2 Không để máy quyết định một mình: phân luồng 3 làn\n", "\n", "Thay một ngưỡng bằng **hai** ngưỡng: điểm cao → **chặn tự động**, điểm thấp → **cho qua tự động**, vùng giữa → **người xem**. Vùng giữa càng rộng thì lỗi tự động càng ít, nhưng người phải đọc càng nhiều. Đây là cách các SOC thật đưa FN về **gần** 0 mà không chặn nhầm hàng loạt: **trả bằng công người**, không phải bằng phép màu của mô hình.\n", "\n", "| Cột | Nghĩa |\n", "|---|---|\n", "| `vùng giữa` | tin có điểm trong khoảng [−độ rộng, +độ rộng] → người xem |\n", "| `chặn nhầm tự động`, `bỏ lọt tự động` | lỗi máy còn tự gây ra (ngoài vùng giữa) |\n", "| `người xem / ngày` | quy ra quy mô `SO_TIN_MOI_NGAY` ở 0.2 |" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# ✏️ SỬA: các độ rộng vùng giữa thử\n", "DO_RONG_VUNG_GIUA = [0.0, 0.25, 0.5, 0.75, 1.0]\n", "dong_lan = []\n", "for do_rong in DO_RONG_VUNG_GIUA:\n", " vung_giua = (diem_thi_cuoi > -do_rong) & (diem_thi_cuoi <= do_rong)\n", " dong_lan.append({\"độ rộng\": do_rong, \"vùng giữa (tin)\": int(vung_giua.sum()),\n", " \"chặn nhầm tự động\": int(((diem_thi_cuoi > do_rong) & (y_thi_mang == 0)).sum()),\n", " \"bỏ lọt tự động\": int(((diem_thi_cuoi <= -do_rong) & (y_thi_mang == 1)).sum()),\n", " \"người xem / ngày\": round(vung_giua.mean() * SO_TIN_MOI_NGAY)})\n", "ba_lan = pd.DataFrame(dong_lan)\n", "ba_lan" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "**Kết quả:**\n", "```\n", " độ rộng vùng giữa (tin) chặn nhầm tự động bỏ lọt tự động người xem / ngày\n", "0 0.00 0 3 8 0\n", "1 0.25 26 2 4 2011\n", "2 0.50 92 0 3 7115\n", "3 0.75 307 0 0 23743\n", "4 1.00 699 0 0 54060\n", "```\n", "📦 **`ba_lan`** = bảng trên.\n", "\n", "👉 **Hiểu:**\n", "- Vùng giữa ±0,25: chỉ 26 / 1.293 tin (2%) cho người xem → lỗi tự động còn 2 + 4.\n", "- ±0,5: máy **không chặn nhầm tin nào** và chỉ bỏ lọt 3, đổi lại người xem 7% số tin.\n", "- ±0,75: **lỗi tự động về 0** — đây là \"FN = 0\" thật sự làm được, nhưng **24% số tin** (~24.000 tin / ngày ở quy mô 100.000) phải qua tay người. Đó là cái giá.\n", "- Câu trả lời cho sếp: *\"FN về 0 thì cần N người đọc mỗi ngày; với đội hiện có, ta chọn vùng giữa ±0,25 và chấp nhận ~4 tin lọt / 1.300.\"*" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "---\n", "\n", "## Phần 8 — Mô hình tốt hơn có khó lừa hơn không? Và dữ liệu trôi\n", "\n", "📍 **Bước 6 — Đánh giá (thử tấn công):** mô hình cuối có khó lừa hơn không, và điểm hôm nay có giữ được không?\n", "\n", "### 8.1 Đòn \"chèn từ an toàn\" của Lab 4, thử lại trên mô hình cuối\n", "\n", "Ở Lab 4, kẻ tấn công chèn một câu \"đời thường\" vào cuối tin rác làm Naive Bayes gần như mù. Mô hình cuối nhìn **cụm ký tự** (thấy cả số điện thoại, ký hiệu `£`) — có vững hơn không?" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# ✏️ SỬA: câu kẻ tấn công chèn (giống Lab 4)\n", "CAU_CHEN = \" ok see you later at home lor going to sleep now love you dear\"\n", "tin_rac_phan_thi = van_ban_thi[y_thi_mang == 1] # 📦 các tin rác của phần thi\n", "dong_tan_cong = []\n", "ong_goc_da_hoc = ong_goc.fit(van_ban_hoc, y_hoc) # ống gốc học trên toàn bộ phần học (= svm_goc ở 0.1)\n", "for ten_ong, ong_tc in [(\"SVM gốc Lab 4 (từ đơn)\", ong_goc_da_hoc), (\"Mô hình cuối (ký tự, đã tinh chỉnh)\", mo_hinh_cuoi)]:\n", " dong_tan_cong.append({\"mô hình\": ten_ong, \"bắt được TRƯỚC\": ong_tc.predict(tin_rac_phan_thi).mean(),\n", " \"bắt được SAU khi chèn\": ong_tc.predict(tin_rac_phan_thi + CAU_CHEN).mean()})\n", "pd.DataFrame(dong_tan_cong).set_index(\"mô hình\").round(3)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "**Kết quả:**\n", "```\n", " bắt được TRƯỚC bắt được SAU khi chèn\n", "SVM gốc Lab 4 (từ đơn) 0.877 0.675\n", "Mô hình cuối (ký tự, đã tinh chỉnh) 0.951 0.908\n", "```\n", "📦 **`tin_rac_phan_thi`** = 163 tin rác của phần thi · **`ong_goc_da_hoc`** = ống gốc đã học lại.\n", "\n", "👉 **Hiểu:** chèn câu \"đời thường\" kéo mô hình gốc từ 88% xuống **68%**; mô hình cuối chỉ rơi từ 95% xuống **91%**. Nhìn **cụm ký tự** (số tổng đài, `£`, mã) khó che hơn vì kẻ tấn công **không thể bỏ** số điện thoại hay đường link — đó là thứ họ cần nạn nhân bấm. Tuy vậy **vẫn rơi**: không mô hình nào miễn nhiễm, cần huấn luyện đối kháng (Lab 4, 10.2) và học lại định kỳ." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 8.2 Dữ liệu trôi — điểm hôm nay không phải điểm tháng sau\n", "\n", "Bộ tin nhắn này không có ngày giờ, nên ta nhắc lại số đo **trên honeypot thật** ở các bài trước:\n", "\n", "- **Lab 6:** số lần tải file của kẻ tấn công rơi từ **7.359 (tháng 7) xuống 77 (tháng 8)** khi một chiến dịch dừng — mô hình học tháng 7 sẽ coi \"tải file\" là bình thường hoặc bất thường sai thời điểm.\n", "- **Lab 3, 9F:** chia học / thi **theo thời gian** (học quá khứ, thi tương lai) — đúng 94,1%: lần này may là không tụt, nhưng **phải đo** mới biết.\n", "- **Lab 6, Phần 8** và **Lab 3, Phần 8:** kẻ tấn công **chủ động** né (thêm nhịp ngẫu nhiên ±30 giây, rải ra 100 IP) — mô hình không biết mình bị né.\n", "\n", "Hệ quả bắt buộc: một mô hình đưa vào dùng cần **(1)** theo dõi tỉ lệ cảnh báo mỗi ngày (đột ngột tăng / giảm = có chuyện), **(2)** định kỳ lấy một mẫu nhỏ cho người gắn nhãn để đo lại precision / recall, **(3)** học lại theo lịch và **so với mô hình cũ trên cùng đề** trước khi thay.\n", "\n", "### 8.3 Khi nào phải học lại (retrain)? Theo dõi chỉ số nào?\n", "\n", "📍 **Bước 6 — Đánh giá (sau khi đưa vào dùng):** mô hình giống **bản đồ GPS**: lúc mua thì đúng, nhưng thành phố **mở đường mới, cấm đường cũ** — không cập nhật thì GPS vẫn tự tin chỉ sai đường. Mô hình cũng vậy: **nó không biết mình đã cũ**, nên **người** phải theo dõi.\n", "\n", "**A. Theo dõi chỉ số nào?** (như **đo huyết áp định kỳ**: chưa ốm vẫn đo, để thấy sớm)\n", "\n", "| Nhóm | Chỉ số theo dõi | Cách đo | Dấu hiệu có chuyện |\n", "|---|---|---|---|\n", "| **Không cần nhãn** (đo được **mỗi ngày**) | **Tỉ lệ cảnh báo** (bao nhiêu % bị chặn / gắn cờ) | đếm từ log của mô hình | đột ngột **tăng vọt** hoặc **tụt về 0** |\n", "| | **Phân bố điểm** của mô hình | biểu đồ điểm như Phần 2 | đám điểm dịch về **giữa** (mô hình \"lưỡng lự\" nhiều hơn) |\n", "| | **Dữ liệu đầu vào** (độ dài tin, tỉ lệ có link, số từ / ký tự **chưa từng gặp**, số dòng mỗi giờ) | so với tuần trước | nguồn dữ liệu **đổi dạng**, **mất** (cảm biến chết = 0 dòng) hoặc chiến dịch mới |\n", "| | **Số ca vùng giữa** chuyển cho người xem (7.2) | đếm | tăng dần → mô hình ngày càng **không chắc** |\n", "| **Cần nhãn** (định kỳ, **người gắn nhãn một mẫu nhỏ**) | **Recall** — có bao nhiêu tấn công bị lọt? | mẫu tấn công được phát hiện **sau** (sự cố, người dùng báo) | tụt **quá mức dao động** đã đo (± ở 1.2) |\n", "| | **Precision** — cảnh báo có đúng không? | khiếu nại \"chặn nhầm\", người xem duyệt lại | tụt → SOC ngập báo giả |\n", "| | **F1, FN, FP** trên **cùng một đề kiểm tra cố định** | chấm lại định kỳ | so với lúc đưa vào dùng |\n", "\n", "**B. Khi nào phải học lại?**\n", "\n", "| Tình huống | Học lại? | Ví dụ |\n", "|---|---|---|\n", "| Recall / Precision đo lại **tụt quá mức dao động** | **Có** — đây là lý do chính | Lab 6: số lần tải file rơi **7.359 → 77** khi một chiến dịch dừng (8.2): \"bình thường\" đã khác |\n", "| Kẻ tấn công có **đòn né mới** (như chèn từ an toàn, 8.1) | **Có** — thêm các mẫu né đã gắn nhãn vào dữ liệu học | đã thấy mô hình cuối vẫn rơi 95% → 91% |\n", "| Tỉ lệ cảnh báo / dữ liệu đầu vào **đổi đột ngột** | **Điều tra trước**: có thể là lỗi thu thập, không phải mô hình | cảm biến chết → 0 cảnh báo **không phải** là \"hết tấn công\" |\n", "| **Đổi định nghĩa nhãn** / chính sách (cái gì tính là \"tấn công\") | **Có** | Lab 6: đổi nhãn là đổi thứ hạng mô hình |\n", "| **Đến lịch** (vd. mỗi tháng) dù chưa thấy gì | **Có**, nhưng chỉ **thay** khi mô hình mới thắng | như bảo dưỡng xe định kỳ |\n", "| Điểm dao động **nhỏ, nằm trong ±** đã đo | **Không** — đó là may rủi của cách chia (1.1, 1.2) | cân 5 lần lệch vài lạng là bình thường |\n", "\n", "**C. Học lại đúng cách** (không thì \"học lại\" làm hỏng mô hình đang tốt):\n", "1. Dữ liệu học **mới + sạch** (bỏ bản sao, nhãn kiểm lại) — ngón cái ✋.\n", "2. So **mô hình mới với mô hình cũ trên cùng một đề** (đề mới nhất, có nhãn), có ± kiểm định chéo — ngón áp út.\n", "3. **Chỉ thay** khi mới thắng **hơn mức dao động**; **giữ mô hình cũ** để quay lại nếu ra thật bị tệ hơn.\n", "\n", "👉 **Nhớ một câu:** *\"Mô hình không biết mình đã cũ — theo dõi mỗi ngày, chấm lại định kỳ, học lại khi tụt, thay khi thắng.\"*\n", "\n", "---\n", "\n", "## Phần 9 — Tổng hợp cả khóa\n", "\n", "📍 **Bước 7 — Quy tắc bàn tay phải:** tổng hợp cả khoá.\n", "\n", "### 9.1 Các mô hình đã xây trong từng notebook\n", "\n", "| Notebook | Bài toán | Dữ liệu thật | Mô hình | Kết quả đo | Chỉ số nên ưu tiên |\n", "|---|---|---|---|---|---|\n", "| `Lab_M2_tung_khoi` (M2.3) | IP \"dò mật khẩu\" hay \"chỉ quét\" | 2.906 IP honeypot | cây quyết định, NB, rừng | đoán bừa 70,5% → cây 87,6% → **90,7%** khi thêm 3 đặc trưng phần mềm; cây sâu 20 **không** hơn (87,5%) | recall lớp dò mật khẩu |\n", "| `Lab3_Honeypot_ML` (5–8) | IP nào bất thường — **không có nhãn** | 2.906 IP, 7 đặc trưng | Isolation Forest | `185.246.128.133` hạng 3 / 2.906; bị **đầu độc** (150 máy giám sát) → rơi xuống hạng 112 | thứ hạng của IP biết là xấu (top 50 SOC xem) |\n", "| `Lab3_Honeypot_ML` (9) | IP dò mật khẩu — **có nhãn** | 2.906 IP, 11 đặc trưng | rừng ngẫu nhiên | accuracy 0,926, F1 0,876, FP 23, FN 20; `balanced`: FN 60 → 33; ngưỡng 0,2: FN 15, FP 26; chia theo /24 hoặc thời gian **không tụt** | recall + FN |\n", "| `Lab4_Email_Phishing_ML` | tin rác / lừa đảo | 5.171 SMS | Perceptron, SVM, LogReg, NB, hội đồng | SVM F1 0,926 (FP 3, FN 20); hội đồng trọng số F1 0,933; NB FP 0 nhưng FN 67; chèn từ an toàn: NB bắt 59% → 3% | F1, rồi ngưỡng theo chi phí |\n", "| `Lab6_Honeypot_Network_Anomaly` | giờ nào bị \"áp đảo\" | 3.363 giờ honeypot | ngưỡng cố định, ngưỡng động, Isolation Forest, K-Means | F1 theo nhãn tự định nghĩa: cố định 0,297 · động **0,556** · IF 0,130 (IF trả lời câu hỏi khác) | recall theo **định nghĩa nhãn** đã viết rõ |\n", "| `Lab7_Honeypot_UBA_KMeans` | phân nhóm hành vi tài khoản | 2.838 IP | K-Means + điểm uy tín | 4 cụm 1.909 / 249 / 521 / 159; không nén log1p → 2.598 dồn 1 cụm; luật cứng lọt 90 tài khoản, K-Means chặn `185.246.128.133` | cụm có **ý nghĩa** với người phân tích (không có F1) |\n", "| **Lab 10 (bài này)** | tin rác | 5.171 SMS | SVM + cụm ký tự, tinh chỉnh | xem Phần 4–7 và báo cáo Phần 10 | chi phí FN / FP |\n", "\n", "### 9.2 Chọn mô hình nào? Khi nào dùng gì, đánh đổi gì\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), Lab 4, **bài này** |\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 2.1–2.3; Lab 4 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.\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 8.1; Lab 4 Phần 10; Lab 3 Phần 8).\n", "\n", "**Tóm tắt theo tình huống** (kèm núm cần chỉnh):\n", "\n", "| Tình huống | Nên thử trước | Vì sao | Núm cần chỉnh |\n", "|---|---|---|---|\n", "| **Văn bản** (email, SMS, lệnh shell, URL) | SVM tuyến tính / Logistic Regression trên TF-IDF | nhanh, mạnh với nhiều cột thưa, đọc được trọng số từng từ (đo ở Phần 5: cây và rừng kém hơn trên dạng dữ liệu này) | cách nhìn (từ / ký tự), `C`, `class_weight`, ngưỡng |\n", "| **Bảng số, ít cột** (đặc trưng IP, phiên đăng nhập) **có nhãn** | rừng ngẫu nhiên / XGBoost | bắt được quan hệ phi tuyến, không cần chuẩn hóa, chịu được ô trống | độ sâu, số cây, `class_weight`, ngưỡng |\n", "| Cần **giải thích** cho người không chuyên / kiểm toán | cây nông, Logistic Regression | đọc được luật / trọng số; mô hình phức tạp thì kèm SHAP | độ sâu cây |\n", "| **Không có nhãn**, tìm cái lạ | Isolation Forest | không cần đáp án; trả về thứ hạng cho người xem | tỉ lệ nghi ngờ, cách chuẩn bị dữ liệu (nén log1p) |\n", "| **Không có nhãn**, muốn **chia nhóm** hành vi | K-Means | gom các thực thể giống nhau, đặt tên cụm bằng con người | số cụm K, nén + chuẩn hóa |\n", "| **Chuỗi thời gian** (số kết nối mỗi giờ) | ngưỡng động (trung bình ± K độ lệch) trước, mô hình sau | đơn giản, tự thích nghi theo mùa, dễ giải thích | K, cửa sổ thời gian |\n", "| Lỗi **rất đắt**, lớp **rất hiếm** | bất kỳ mô hình xếp hạng tốt (AUC cao) + **3 làn** | ngưỡng + người xem quan trọng hơn chọn mô hình | ngưỡng, độ rộng vùng giữa |\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, Lab 3 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, Lab 4 Phần 7, **bài này 5.1** |\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 | Lab 4 Phần 6, **bài này 5.1** |\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 | Lab 4 Phần 4–5, **bài này** (mô hình chính) |\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, **bài này 5.1** |\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, **bài này 5.1** |\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, **bài này 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 | **bài này 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", "**Trong bài này đã đo được (5.1, 5.2):** trên **văn bản**, SVM tuyến tính (F1 0,926) và Logistic thắng cây quyết định (0,854), rừng (0,881) và XGBoost (0,872); mạng nơ-ron MLP **ngang** SVM nhưng chậm hơn ~120 lần. Trên **bảng số** của honeypot thì ngược lại: rừng rất tốt (Lab 3: F1 0,876). Đúng ý \"No Free Lunch\" ở đầu mục.\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) — bảng 9.1 ở trên; quy tắc bàn tay ở Lab 3 Phần 11.\n", "\n", "📌 **Pipeline xử lý luôn là 7 bước** (bảng chi tiết ở đầ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** ✋\n", "\n", "### 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 điều **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 điều:\n", "\n", "| Ngón | Điều | Câu hỏi phải tự trả lời | Trong bài này | Quên điều này thì… |\n", "|---|---|---|---|---|\n", "| 👍 **1. Ngón cái** | **DỮ LIỆU sạch** | Dữ liệu có bản sao, có lộ đề không? | bỏ bản sao trước khi chia, y hệt Lab 4 (0.1) | đề thi có sẵn đáp án → điểm cao ảo |\n", "| ☝️ **2. Ngón trỏ** | **ĐẶC TRƯNG đúng** | Máy \"nhìn\" gì trong tin? Thêm đặc trưng có giúp thật không? | cụm ký tự 2 – 5: F1 kiểm định chéo 0,953 so với từ đơn 0,930 (4.2), thắng 5 / 5 phần (4.3) | thêm đặc trưng mà không kiểm tra thắng thật hay may |\n", "| ✋ **3. Ngón giữa** | **CHỌN CÔNG CỤ** | Đổi mô hình có đáng không? | 7 mô hình trên cùng dữ liệu (5.1): SVM / Logistic hợp văn bản; Naive Bayes bỏ lọt 67 | đổi mô hình trước khi đo cho chắc |\n", "| 💍 **4. Ngón áp út** | **ĐO trung thực** | Chênh lệch có thật không? FN = 0 giá bao nhiêu? | chỉ đổi cách chia mà F1 chạy 0,906 – 0,959 (1.1); kiểm định chéo 0,930 ± 0,021 (1.2) · coi FN đắt gấp 50: bỏ lọt còn 5 nhưng chặn nhầm 74 (2.3) · tinh chỉnh chỉ trên phần học → phần thi F1 0,966, bỏ lọt 8 (6.1) | tin một con số may rủi; tinh chỉnh trên đề thi |\n", "| 🤙 **5. Ngón út** | **GIẢI THÍCH & CẢNH GIÁC** | Lỗi còn lại ai xem? Mô hình tốt hơn có khó lừa hơn không? | vùng giữa chuyển cho người xem (7.2) · chèn từ an toàn: mô hình cuối 95% → 91%, mô hình gốc 88% → 68% (8.1) | để máy quyết định một mình; tưởng tốt hơn là không bị lừa |\n", "\n", "\n", "**Gói lại bằng bàn tay ✋** — mọi thứ ôn ở 0.2b, 0.2c và 8.3 đều quay về 5 ngón:\n", "\n", "| Ngón | Ôn / cải thiện / theo dõi |\n", "|---|---|\n", "| 👍 **Dữ liệu sạch** | học lại bằng dữ liệu **mới + sạch**; theo dõi dữ liệu đầu vào **trôi** hoặc **mất** (8.3) |\n", "| ☝️ **Đặc trưng đúng** | Precision và Recall **cùng kém** → cần **đặc trưng mới**, đổi ngưỡng không cứu được (0.2c) |\n", "| ✋ **Chọn công cụ** | đổi mô hình **sau khi chẩn đoán**, không đổi mò (0.2c) |\n", "| 💍 **Đo trung thực** | chọn **đúng thước đo**: accuracy lừa khi lệch, xem Precision / Recall / F1 (0.2b); so mô hình mới – cũ **trên cùng đề** (8.3) |\n", "| 🤙 **Giải thích & cảnh giác** | theo dõi sau khi đưa vào dùng, kẻ tấn công né → **học lại** (8.3); ca lưng chừng cho **người xem** |\n", "\n", "### 9.3 \"Chưa đạt kỳ vọng\" — danh sách kiểm tra\n", "\n", "1. **Đã đo đúng chưa?** kiểm định chéo, bỏ bản sao, không rò rỉ nhãn (Lab 3 9B: thêm cột tạo ra nhãn → 100% ảo), chia theo thời gian / theo dải IP.\n", "2. **Đã so với mốc lười chưa?** (luôn đoán lớp đông nhất; luật từ khóa; ngưỡng cố định).\n", "3. **Đã định nghĩa lỗi nào đắt chưa?** → chọn ngưỡng theo chi phí trên phần học (2.3) trước khi đổi bất cứ thứ gì khác.\n", "4. **Đường cong học còn đi lên?** → thêm dữ liệu có nhãn. **Đã nằm ngang?** → đặc trưng mới (thường lời nhất: Lab M2 +3,1 điểm, Phần 4 bài này).\n", "5. **Đổi mô hình** theo bảng 9.2, so trên **cùng** cách chia, có ± độ lệch.\n", "6. **Tinh chỉnh** bằng GridSearchCV trên phần học; phần thi dùng một lần.\n", "7. **Đọc lỗi còn lại**, phân loại sửa được / nhãn đáng ngờ / mơ hồ thật sự.\n", "8. **Đưa vào dùng có 3 làn + theo dõi trôi + học lại có so sánh.**\n", "\n", "### 9.4 Những điều bắt buộc phải chấp nhận\n", "\n", "- **FN = 0 và FP = 0 cùng lúc là không thể** khi hai lớp chồng nhau (2.1, 2.2). Chỉ chọn được **điểm cân bằng** theo chi phí.\n", "- **Điểm dao động theo cách chia** (1.1): chênh lệch nhỏ hơn độ dao động không phải là \"tốt hơn\".\n", "- **Có những mẫu mơ hồ thật sự** — con người cũng không chắc (7.1) — và **nhãn có thể sai**.\n", "- **\"Tốt\" phụ thuộc định nghĩa nhãn** (Lab 6: đổi nhãn là đổi thứ hạng mô hình).\n", "- **Dữ liệu trôi và kẻ tấn công thích nghi** (8.1, 8.2): mô hình tốt hôm nay cần theo dõi và học lại.\n", "- **Mô hình chỉ là một lớp phòng thủ**: luôn cần người xem vùng giữa và các lớp khác (chặn link, xác minh 2 bước, đào tạo người dùng).\n", "\n", "### 9.5 Câu hỏi hay gặp\n", "\n", "| Câu hỏi | Trả lời ngắn |\n", "|---|---|\n", "| \"Làm sao cho FN = 0?\" | Hạ ngưỡng được, nhưng phải trả bằng FP (2.1). Cách đúng: chọn ngưỡng theo chi phí (2.3) + vùng giữa cho người xem (7.2) + mô hình tốt hơn (4–6). |\n", "| \"Accuracy 99% là tốt chứ?\" | Chưa chắc: lớp hiếm thì mô hình lười cũng ~90% (0.2). Xem precision, recall, FN, FP. |\n", "| \"Sao F1 của tôi khác bạn bên cạnh?\" | Khác `random_state` / khác cách chia → khác điểm (1.1). So sánh phải dùng **cùng** cách chia. |\n", "| \"Có nên chọn tham số nhìn vào điểm phần thi?\" | Không. Chọn bằng kiểm định chéo trên phần học (6.1); phần thi dùng một lần. |\n", "| \"Mạng nơ-ron / AI to hơn có tốt hơn không?\" | Không tự động: đo ở 5.1. Dữ liệu nhỏ và dạng văn bản ngắn thì mô hình tuyến tính thường ngang hoặc hơn, nhanh hơn hàng chục lần, dễ giải thích hơn. |\n", "| \"Thêm dữ liệu luôn tốt hơn?\" | Chỉ khi đường cong học còn đi lên (3.1). |\n", "| \"Mô hình tốt trên lớp, ra thật lại kém?\" | Dữ liệu trôi, kẻ tấn công né, hoặc đề thi lúc học bị lộ (bản sao). Theo dõi và học lại (8.2). |\n", "\n", "### 9.6 Quy tắc bỏ túi — 4 chỉ số (nhắc lại 0.2b)\n", "\n", "| Chỉ số | Mẫu số là gì? | Câu thần chú |\n", "|---|---|---|\n", "| **Accuracy** | **tất cả** mẫu | *\"Đừng để con số 99% đánh lừa khi dữ liệu lệch!\"* |\n", "| **Precision** | những gì máy **cảnh báo** (TP + FP) | *\"Báo phát nào, trúng phát nấy.\"* |\n", "| **Recall** | những gì **thật sự xấu** (TP + FN) | *\"Thà bắt nhầm còn hơn bỏ sót.\"* |\n", "| **F1** | gộp P và R: 2 × P × R / (P + R) | *\"Trọng tài công tâm giữa Báo nhầm và Bỏ sót.\"* |\n", "\n", "---\n", "\n", "## Phần 10 — Báo cáo\n", "\n", "📍 **Bước 7 — Quy tắc bàn tay phải:** báo cáo." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from pathlib import Path\n", "THU_MUC_RA = Path.home() / \"lab\" / \"out_lab10\"\n", "THU_MUC_RA.mkdir(parents=True, exist_ok=True)\n", "tot_nhat_dac_trung = so_sanh_dac_trung[\"F1 kiểm định chéo\"].idxmax()\n", "tot_nhat_mo_hinh = so_sanh_mo_hinh[\"F1 kiểm định chéo\"].idxmax()\n", "nguong_chon = bang_chi_phi[bang_chi_phi[\"1 FN đắt bằng ... FP\"] == CHI_PHI_FN / CHI_PHI_FP].iloc[0]\n", "bao_cao = f'''BÁO CÁO MODULE 10: ĐÁNH GIÁ LẠI VÀ CẢI THIỆN MÔ HÌNH LỌC TIN RÁC (SMS Spam Collection, {len(tin):,} tin thật không trùng)\n", "=====================================================================================\n", "Phần thi: {len(y_thi)} tin, {int(y_thi.sum())} tin rác\n", "Mô hình gốc (Lab 4, SVM từ đơn) : F1 {kq_goc['F1']:.3f} | chặn nhầm {kq_goc['FP']} | bỏ lọt {kq_goc['FN']} | AUC {auc_goc:.4f}\n", "Độ tin cậy của phép đo : F1 kiểm định chéo {f1_5_phan.mean():.3f} ± {f1_5_phan.std():.3f}; 10 cách chia: {min(f1_moi_lan_chia):.3f} - {max(f1_moi_lan_chia):.3f}\n", "FN = 0 bằng ngưỡng : phải chặn nhầm {dong_nguong[2]['chặn nhầm (FP)']} tin thật / {int((y_thi == 0).sum())}\n", "Ngưỡng theo chi phí {CHI_PHI_FN}:{CHI_PHI_FP} : chặn nhầm {int(nguong_chon['PHẦN THI: chặn nhầm (FP)'])} | bỏ lọt {int(nguong_chon['bỏ lọt (FN)'])}\n", "Thêm dữ liệu : F1 kiểm định chéo {duong_cong['F1 kiểm định chéo'].iloc[-2]:.3f} -> {duong_cong['F1 kiểm định chéo'].iloc[-1]:.3f} ở 2 mức dữ liệu cuối\n", "Cách nhìn tốt nhất (Phần 4) : {tot_nhat_dac_trung} (F1 kiểm định chéo {so_sanh_dac_trung.loc[tot_nhat_dac_trung, 'F1 kiểm định chéo']:.3f})\n", "Mô hình tốt nhất trên từ đơn : {tot_nhat_mo_hinh} (F1 kiểm định chéo {so_sanh_mo_hinh.loc[tot_nhat_mo_hinh, 'F1 kiểm định chéo']:.3f})\n", "Mô hình cuối (tinh chỉnh) : {tim_num.best_params_} | F1 {kq_cuoi['F1']:.3f} | chặn nhầm {kq_cuoi['FP']} | bỏ lọt {kq_cuoi['FN']}\n", "\n", "Khuyến nghị:\n", "- Chọn ngưỡng theo chi phí FN/FP trên phần học, không đòi FN = 0.\n", "- Đầu tư vào đặc trưng trước khi đổi sang mô hình phức tạp; so sánh luôn kèm ± độ dao động.\n", "- Vùng giữa cho người xem; theo dõi tỉ lệ cảnh báo, đo lại định kỳ, học lại có so sánh với mô hình cũ.\n", "'''\n", "(THU_MUC_RA / \"bao_cao_lab10.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_lab10/bao_cao_lab10.txt`: `Mô hình gốc ... F1 0.926 | chặn nhầm 3 | bỏ lọt 20 | AUC 0.9939` · `F1 kiểm định chéo 0.930 ± 0.021; 10 cách chia: 0.906 - 0.959` · `FN = 0 bằng ngưỡng: phải chặn nhầm 425 tin thật / 1130` · `Ngưỡng theo chi phí 10:1: chặn nhầm 27 | bỏ lọt 9` · `Thêm dữ liệu: 0.930 -> 0.930` · `Cách nhìn tốt nhất: 4. từ + ký tự (0.955)` · `Mô hình tốt nhất trên từ đơn: SVM tuyến tính (0.930)` · `Mô hình cuối: C 0.3, balanced | F1 0.966 | chặn nhầm 3 | bỏ lọt 8`, rồi 3 khuyến nghị.\n", "\n", "👉 **Hiểu:** đổi các ô ✏️ (`CHI_PHI_FN`, `SO_TIN_MOI_NGAY`, `LUOI_NUM`, `dac_trung_so`...) rồi **Run All** → báo cáo tự cập nhật." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "**Câu hỏi suy nghĩ:**\n", "1. Ở 2.3, đặt `CHI_PHI_FN = 100`. Ngưỡng dời về đâu, FP tăng bao nhiêu? Ở tổ chức bạn, tỉ lệ đó nên là bao nhiêu?\n", "2. Ở 4.2, thêm một số đo của riêng bạn vào `dac_trung_so` (ví dụ: có chữ \"£\" hoặc \"$\" không). F1 có tăng **vượt** độ dao động không?\n", "3. Ở 7.2, độ rộng nào cho số \"người xem / ngày\" mà đội của bạn đọc nổi?\n", "4. Chọn một notebook trong bảng 9.1: nếu sếp nói \"mô hình chưa đạt\", bạn sẽ thử đòn bẩy nào trước, vì sao?" ] } ], "nbformat": 4, "nbformat_minor": 5, "metadata": { "kernelspec": { "name": "csai", "display_name": "Python (lớp CSAI)", "language": "python" }, "language_info": { "name": "python" } } }