Files
labs/Notebooks-CSAI/Lab4_Email_Phishing_ML.ipynb

96 KiB

Module 4 — Máy học chống tin nhắn lừa đảo / rác, trên tin nhắn SMS THẬT

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. 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. Sửa hỏng? Menu Kernel → Restart Kernel and Run All Cells.

🎯 Mục tiêu của bài

Học xong bài này, bạ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.
  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.
  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).
  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.
  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ủ.

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.

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.

🗺️ Bài này: bài toán gì, mô hình nào, vì sao?

  • 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.
  • 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ệ.
  • 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).
  • 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.

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:

Bước Câu hỏi phải trả lời Phầ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
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
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)
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
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)
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
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

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.

📌 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.

📖 Thuật ngữ

Thuật ngữ Nghĩa
ham / spam tin thật / tin rác-lừa đảo. Trong code: 0 = ham, 1 = spam
đặc trưng (feature) một con số mô tả tin nhắn (ở đây: điểm của từng từ)
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
TP / TN / FP / FN bắt đúng spam / cho qua đúng ham / chặn nhầm ham / bỏ lọt spam
precision trong các tin máy chặn, bao nhiêu phần đúng là spam (cao = ít chặn nhầm)
recall trong các tin spam thật, máy bắt được bao nhiêu phần (cao = ít bỏ lọt)
F1 một con số gộp precision và recall (cao khi cả hai cùng cao)
.shape kích thước bảng / ma trận: (số hàng, số cột)

Trước khi bắt đầu — Chọn mô hình nào? Khi nào dùng gì, đánh đổi gì

(Đọ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.)

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:

① Có đáp án (nhãn) không?

  • 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.
  • 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). ⚠️ 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).
  • 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.

② Dữ liệu dạng gì?

Dạng dữ liệu Thường dùng Trong khoá
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
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
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
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

③ Sợ bỏ lọt hay sợ báo nhầm?

  • 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.
  • ⚠️ 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.
  • 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.
  • 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).

④ Ràng buộc khi chạy thật

  • 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).
  • 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.
  • 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.
  • 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).

Bảng tra nhanh: khi nào dùng gì, đánh đổi gì

Công cụ Dùng khi Được Phải chấp nhận Trong khoá
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
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
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
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
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
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
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
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
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
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
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
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
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ỏ)
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

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.

📌 Pipeline xử lý luôn là 7 bước (chi tiết ở bảng đầu bài):

  1. Thu thập dữ liệu — có nhãn hay không nhãn?
  2. Làm sạch dữ liệu
  3. Chọn mô hình phù hợp với bài toán (4 câu hỏi ① – ④ ở trên)
  4. Coding
  5. Lưu ý khi coding — những lỗi không báo lỗi
  6. Đánh giá các mô hình
  7. Quy tắc bàn tay phải ✋

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?

📍 Bước 1 — Thu thập dữ liệu (0.1) và Bước 2 — Làm sạch (0.2).

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)

Câu hỏi Trả lời cho dữ liệu bài này
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
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
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
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
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)

Giờ đọc file và đếm (0.1), rồi bỏ bản sao (0.2).

0.1 Đọc và xem tin nhắ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.

In [ ]:
import time
import warnings
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
%matplotlib inline
pd.set_option("display.max_colwidth", 120)
pd.set_option("display.width", 200)
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ả

# 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;
# 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);
# encoding="utf-8" = bảng mã của file (đọc sai bảng mã thì ký tự £ thành "£")
du_lieu = pd.read_csv("/opt/csai/labs/day4_phishing/SMSSpamCollection", sep="\t", header=None,
                      names=["nhan", "noi_dung"], quoting=3, encoding="utf-8")
# 📦 du_lieu = bảng 2 cột: nhan (ham / spam), noi_dung (tin nhắn)
print("du_lieu.shape =", du_lieu.shape, "-> (số tin nhắn, số cột)")
dem = du_lieu.nhan.value_counts()                  # đếm số tin theo từng nhãn
print(f"Có {len(du_lieu):,} tin: {dem['ham']:,} tin thường (ham) + {dem['spam']:,} tin rác (spam)")
print(f"Tỉ lệ spam: {(du_lieu.nhan == 'spam').mean():.1%}")
print("\n3 tin THẬT (ham) và 3 tin RÁC (spam) đầu tiên:")
print(pd.concat([du_lieu[du_lieu.nhan == "ham"].head(3), du_lieu[du_lieu.nhan == "spam"].head(3)]).to_string())

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...).

📦 du_lieu = bảng 5.574 tin, 2 cột nhan, noi_dung · dem = số tin theo nhã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.

0.2 Làm sạch dữ liệu: cái bẫy bản sao

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.

In [ ]:
from sklearn.model_selection import train_test_split
so_ban_sao = du_lieu.noi_dung.duplicated().sum()      # duplicated() = True cho tin trùng với một tin phía trê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())
# 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?
hoc_tam, thi_tam = train_test_split(du_lieu.index, test_size=0.25, random_state=RANDOM_STATE, stratify=du_lieu.nhan)
lo_de = du_lieu.noi_dung[thi_tam].isin(set(du_lieu.noi_dung[hoc_tam])).sum()
print(f"Nếu không bỏ bản sao: {lo_de} tin trong đề thi ĐÃ CÓ trong phần học (lộ đề)")

tin = du_lieu.drop_duplicates("noi_dung").reset_index(drop=True)     # giữ 1 bản cho mỗi nội dung
y = (tin.nhan == "spam").astype(int)                                  # nhãn dạng số: 1 = spam, 0 = ham
# 📦 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
print(f"\nSau khi bỏ bản sao: {len(tin)} tin, {y.sum()} spam ({y.mean():.1%})")

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%)

📦 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ự.

👉 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.

0.3 Vậy đây là bài toán gì? Chọn cách giải nào?

📍 Bước 3 — Chọn mô hình phù hợp với bài toá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ế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:

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
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
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
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)
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
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)

👉 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.


Phần 1 — Luật cứng (từ khóa) thất bại thế nào?

📍 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.

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.

Luật so khớp thế nào (đọc trước khi sửa TU_KHOA):

  • Tin SMS không có tiêu đề: luật dò trong toàn bộ nội dung tin nhắ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.
  • 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.
In [ ]:
# ✏️ 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
TU_KHOA = ["free", "win", "winner", "prize", "call now", "claim", "urgent", "cash", "txt", "reply"]
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)
chu_thuong = tin.noi_dung.str.lower()               # đổi cả nội dung tin sang chữ thường
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
chan_nham = ((bi_chan == 1) & (y == 0)).sum()
bo_lot = ((bi_chan == 0) & (y == 1)).sum()
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)")
print("\nVí dụ tin THẬT bị chặn nhầm:")
print(tin.noi_dung[(bi_chan == 1) & (y == 0)].head(4).to_string())
print("\nVí dụ tin RÁC lọt qua:")
print(tin.noi_dung[(bi_chan == 0) & (y == 1)].head(4).to_string())

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.

📦 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.

👉 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.

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.


Phần 2 — Biến chữ thành số, chia học / thi

📍 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.

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.

Khái niệm — điểm của một từ trong một tin = TF × IDF:

  • TF (tần suất trong tin): từ lặp lại nhiều trong tin này → điểm cao hơ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.

Thuật toán (4 bước, máy làm hết):

  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ự).
  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.
  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ỏ.
  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.

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ố.

Ứ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á. 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).

2.1 Thử TF-IDF trên 3 tin

Ô 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.

In [ ]:
from sklearn.feature_extraction.text import TfidfVectorizer
# ✏️ SỬA: 3 câu thử (tiếng Anh, như dữ liệu; tiếng Việt: xem 2.1b)
cau = ["WIN a FREE prize now, call to claim", "are you free now? can i call you", "see you at home tonight"]
thu = TfidfVectorizer()
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ố
print("Từ điển:", list(thu.get_feature_names_out()))
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())

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).

👉 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.

2.1b (Tuỳ chọn) Còn tin nhắn tiếng Việt thì sao?

TfidfVectorizer đọc được chữ có dấu, nhưng tiếng Việt có 3 điểm cần biết:

  • 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.
  • Từ 1 ký tự bị bỏ ("ở", "à", "ạ").
  • 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.
In [ ]:
import unicodedata
# ✏️ SỬA: thử tin nhắn tiếng Việt của bạn
cau_viet = ["Chúc mừng bạn trúng thưởng 50 triệu, bấm link để nhận ngay",
            "Tối nay mình ở nhà, mai chuyển khoản cho bạn nhé",
            "Tài khoản của bạn bị khoá, chuyển khoản phí 200k để mở lại"]
tach_don = TfidfVectorizer().fit(cau_viet)                          # mặc định: mỗi âm tiết là một "từ"
print("Từ điển (từ đơn):", list(tach_don.get_feature_names_out()))
tach_cap = TfidfVectorizer(ngram_range=(1, 2)).fit(cau_viet)        # thêm cặp 2 âm tiết liền nhau
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])

# 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
ba_kieu = ["chuyển khoản", unicodedata.normalize("NFD", "chuyển khoản"), "chuyen khoan"]
print("\n3 kiểu gõ ->", list(TfidfVectorizer().fit(ba_kieu).get_feature_names_out()))
print("Sau khi chuẩn hoá NFC ->", list(TfidfVectorizer().fit([unicodedata.normalize("NFC", c) for c in ba_kieu]).get_feature_names_out()))

Kết quả:

  • 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ỏ).
  • 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…
  • 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'].

📦 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ữ.

👉 Hiểu:

  • Tiếng Việt khác tiếng Anh ở đâu khi xử lý?
Tiếng Anh Tiếng Việt
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)
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
Từ 1 ký tự ít quan trọng (a, I) có nghĩa (ở, à, ạ) nhưng bị bỏ mặc định
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ừ
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
  • 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á.
  • 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.
  • 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.

2.2 Chia học / thi, rồi học từ điển CHỈ từ phần học

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:

SMSSpamCollection (5.574 tin) --0.1 đọc--> du_lieu --0.2 bỏ bản sao--> tin (5.171 tin)
                                                                         |-- 75% --> phần HỌC: máy được xem cả nội dung lẫn nhãn
                                                                         '-- 25% --> phần THI: máy KHÔNG được học, chỉ dùng để chấm điểm

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]].

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:

phần thi số tin thi (tin rác) F1 trung bình F1 thấp nhất – cao nhất
10% 518 (65) 0,945 0,917 – 0,985
20% 1.035 (131) 0,939 0,911 – 0,973
25% (bài này) 1.293 (163) 0,936 0,906 – 0,959
30% 1.552 (196) 0,936 0,911 – 0,961
50% 2.586 (327) 0,931 0,910 – 0,940

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).

In [ ]:
# ✏️ 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
TI_LE_THI = 0.25
vi_tri_hoc, vi_tri_thi = train_test_split(tin.index, test_size=TI_LE_THI, random_state=RANDOM_STATE, stratify=y)
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)")
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]))
tfidf = TfidfVectorizer()
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ố
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)
y_hoc, y_thi = y[vi_tri_hoc], y[vi_tri_thi]
tu_dien = tfidf.get_feature_names_out()
# 📦 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ừ
print("X_hoc.shape =", X_hoc.shape, "| X_thi.shape =", X_thi.shape, "-> (số tin, số từ trong từ điển)")
print(f"Phần thi: {len(y_thi)} tin, {y_thi.sum()} spam")
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")

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

📦 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).

👉 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.


Phần 3 — Perceptron: "anh bảo vệ" cộng điểm

📍 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.

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).

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.

Vì sao học Perceptron ở đây?

  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.
  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ả.
  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.

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:

điểm = (trọng số từ 1 × giá trị TF-IDF từ 1) + (trọng số từ 2 × ...) + ... + bias

Đ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.

In [ ]:
from sklearn.linear_model import Perceptron
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, confusion_matrix

def do_luong(ten, du_doan):
    """Trả về 1 hàng kết quả: accuracy, precision, recall, F1, số chặn nhầm (FP), số bỏ lọt (FN)."""
    tn, fp, fn, tp = confusion_matrix(y_thi, du_doan).ravel()
    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
            "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}

perceptron = Perceptron(random_state=RANDOM_STATE).fit(X_hoc, y_hoc)
# 📦 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)
print(pd.DataFrame([do_luong("Perceptron", perceptron.predict(X_thi))]).round(3).to_string(index=False))

trong_so = perceptron.coef_[0]                                   # 1 trọng số cho mỗi từ trong từ điể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]]))
print("8 từ trọng số ÂM lớn nhất (kéo về ham)    :", list(tu_dien[np.argsort(trong_so)[:8]]))
print("bias =", round(perceptron.intercept_[0], 3))

# Tự tính "điểm" của 1 tin bằng tay theo công thức, so với máy
i = 0
x = X_thi[i]                                                      # hàng TF-IDF của tin thi đầu tiên
diem_tay = x.toarray()[0] @ trong_so + perceptron.intercept_[0]   # @ = nhân từng cặp rồi cộng lại
print(f"\nTin: {tin.noi_dung[vi_tri_thi[i]]!r}")
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'}")

Kết quả:

   mô hình  accuracy  precision  recall    F1  chặn nhầm (FP)  bỏ lọt (FN)
Perceptron     0.976      0.912   0.896 0.904              14           17
8 từ trọng số DƯƠNG lớn nhất (kéo về spam): ['txt', 'ringtone', 'services', 'uk', 'darling', 'reply', '07090201529', '146tf150p']
8 từ trọng số ÂM lớn nhất (kéo về ham)    : ['place', 'gd', 'mail', 'princess', 'didn', 'wearing', 'everything', 'drop']
bias = -0.29
Tin: "RCT' THNQ Adrian for U text. Rgds Vatian"
Điểm tự tính = 0.046 | máy tính (decision_function) = 0.046 -> SPAM

📦 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ố.

👉 Hiểu:

  • Đọ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.
  • 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.
  • 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ẽ".

Phần 4 — SVM: ranh giới có vùng đệm rộng nhất

📍 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.

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.

4.0 Hình minh hoạ: ranh giới "sát sạt" và ranh giới có vùng đệm

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.

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.

In [ ]:
from sklearn.svm import SVC
# 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)
ngau_nhien_gia = np.random.RandomState(3)
P = np.vstack([ngau_nhien_gia.normal([2, 2], 0.7, (20, 2)), ngau_nhien_gia.normal([5, 4.5], 0.7, (20, 2))])
nhan_gia = np.array([0] * 20 + [1] * 20)
per_gia = Perceptron(random_state=0, max_iter=1000).fit(P, nhan_gia)
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

def gan_nhat(w, b):
    """Khoảng cách từ ranh giới w·x + b = 0 tới chấm gần nhất."""
    return np.min(np.abs(P @ w + b)) / np.linalg.norm(w)

xs = np.linspace(0, 7, 100)
hinh, o = plt.subplots(1, 2, figsize=(12, 5), sharey=True)
for truc, ten, m in zip(o, ["Perceptron", "SVM"], [per_gia, svm_gia]):
    w, b = m.coef_[0], m.intercept_[0]
    truc.scatter(P[:, 0], P[:, 1], c=np.where(nhan_gia == 1, "red", "blue"), s=30)
    truc.plot(xs, -(w[0] * xs + b) / w[1], "k-", label="ranh giới")
    if ten == "SVM":
        for lech in (-1, 1):                                    # mép vùng đệm: w·x + b = ±1
            truc.plot(xs, -(w[0] * xs + b + lech) / w[1], "k--", lw=1)
        truc.scatter(m.support_vectors_[:, 0], m.support_vectors_[:, 1], s=220, facecolors="none",
                     edgecolors="green", lw=2, label="support vector")
    truc.set_title(f"{ten}: ranh giới cách chấm gần nhất {gan_nhat(w, b):.2f}")
    truc.set_xlim(0, 7); truc.set_ylim(0, 7); truc.legend(loc="upper left")
plt.tight_layout(); plt.show()

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.

📦 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ả.

👉 Hiểu:

  • 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.
  • 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.
  • 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.

4.1 SVM trên tin nhắn thật

In [ ]:
from sklearn.svm import LinearSVC, SVC
svm = LinearSVC(random_state=RANDOM_STATE).fit(X_hoc, y_hoc)
print(pd.DataFrame([do_luong("SVM (tuyến tính)", svm.predict(X_thi))]).round(3).to_string(index=False))

# Đếm support vectors: SVC(kernel="linear") cùng ý tưởng, cho biết các tin "giữ ranh giới"
svc = SVC(kernel="linear").fit(X_hoc, y_hoc)
so_sv = svc.n_support_                                             # [số tin ham, số tin spam] là support vector
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%})")

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%)

📦 svm = SVM tuyến tính đã học.

👉 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.


Phần 5 — Kernel trick: khi một đường thẳng không đủ

📍 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ế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.

Để 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.

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.

In [ ]:
# 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)
hai_dac_trung = np.log1p(pd.DataFrame({"do_dai": tin.noi_dung.str.len(), "so_chu_so": tin.noi_dung.str.count(r"\d")}))
A_hoc, A_thi = hai_dac_trung.loc[vi_tri_hoc], hai_dac_trung.loc[vi_tri_thi]
# Lưới điểm phủ cả hình, để tô màu vùng đoán
xx, yy = np.meshgrid(np.linspace(0, 6.5, 300), np.linspace(0, 4.2, 300))
luoi = pd.DataFrame({"do_dai": xx.ravel(), "so_chu_so": yy.ravel()})
hinh, o = plt.subplots(1, 2, figsize=(14, 5))
for truc, kernel in zip(o, ["linear", "rbf"]):
    m = SVC(kernel=kernel, C=1, gamma="scale").fit(A_hoc, y_hoc)
    truc.contourf(xx, yy, m.predict(luoi).reshape(xx.shape), alpha=0.25, cmap="coolwarm")
    mau = np.where(y_thi == 1, "red", "blue")
    truc.scatter(A_thi.do_dai, A_thi.so_chu_so, c=mau, s=6, alpha=0.5)
    p = m.predict(A_thi)
    truc.set_title(f"kernel='{kernel}': đúng {accuracy_score(y_thi, p):.1%}, recall {recall_score(y_thi, p):.1%}")
    truc.set_xlabel("log(1 + độ dài tin)"); truc.set_ylabel("log(1 + số chữ số)")
plt.tight_layout(); plt.show()

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.

👉 Hiểu:

  • 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á).
  • 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%).
  • 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.

Phần 6 — Logistic Regression: từ "Có / Không" sang xác suất

📍 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.

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:

xác suất = 1 / (1 + e^(−điểm))

6.1 Hàm sigmoid trông thế nào?

In [ ]:
diem_vd = np.array([-6, -3, -1, 0, 1, 3, 6])
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))
z = np.linspace(-8, 8, 200)
plt.figure(figsize=(7, 3)); plt.plot(z, 1 / (1 + np.exp(-z))); plt.axhline(0.5, ls="--", c="gray")
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()

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.

👉 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.

6.2 Huấn luyện — và vấn đề "lớp hiếm"

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.

In [ ]:
from sklearn.linear_model import LogisticRegression
lr_mac_dinh = LogisticRegression(max_iter=1000).fit(X_hoc, y_hoc)
logistic = LogisticRegression(max_iter=1000, class_weight="balanced").fit(X_hoc, y_hoc)
# 📦 logistic = Logistic Regression có cân bằng lớp (dùng ở các phần sau)
print(pd.DataFrame([do_luong("Logistic mặc định", lr_mac_dinh.predict(X_thi)),
                    do_luong("Logistic class_weight='balanced'", logistic.predict(X_thi))]).round(3).to_string(index=False))

Kết quả:

                         mô hình  accuracy  precision  recall    F1  chặn nhầm (FP)  bỏ lọt (FN)
               Logistic mặc định     0.963      0.967   0.730 0.832               4           44
Logistic class_weight='balanced'     0.978      0.904   0.926 0.915              16           12

📦 logistic = Logistic Regression có cân bằng lớp (dùng ở các phần sau).

👉 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.

6.3 Phân luồng 3 làn theo xác suất

In [ ]:
xac_suat = logistic.predict_proba(X_thi)[:, 1]          # cột [:, 1] = xác suất là spam của từng tin thi
# ✏️ SỬA: 2 ngưỡng phân luồng
NGUONG_CACH_LY, NGUONG_CHAN = 0.5, 0.9
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"))
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"])
bang_lan.columns = ["số tin", "trong đó spam thật"]
# 📦 xac_suat = xác suất spam của từng tin thi · lan = làn được xếp
print(bang_lan.to_string())
print("\nVí dụ 3 tin ở làn CÁCH LY:")
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]:
    print(f"  {p_:.0%} | {nd}")

Kết quả:

                        số tin  trong đó spam thật
1. CHO QUA                1126                  12
2. CÁCH LY (người xem)      75                  60
3. CHẶN                     92                  91

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.

📦 xac_suat = xác suất spam của 1.293 tin thi · lan = làn được xếp.

👉 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.


Phần 7 — Naive Bayes: thám tử thống kê

📍 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ứ.

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.

7.1 Tự tính "bằng chứng" của một từ từ dữ liệu học

In [ ]:
# ✏️ SỬA: từ muốn soi (viết thường), ví dụ "prize", "call", "home", "love"
TU = "free"
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")
so_spam, so_ham = int((co_tu & (y_hoc == 1)).sum()), int((co_tu & (y_hoc == 0)).sum())
print(f"Trong phần học có {co_tu.sum()} tin chứa '{TU}': {so_spam} spam, {so_ham} ham")
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%}")
print(f"(trong khi tỉ lệ spam chung chỉ {y_hoc.mean():.0%})")

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%)

👉 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".

7.2 Huấn luyện, từ "tố cáo" mạnh nhất, và tốc độ

In [ ]:
from sklearn.naive_bayes import MultinomialNB
naive_bayes = MultinomialNB().fit(X_hoc, y_hoc)
print(pd.DataFrame([do_luong("Naive Bayes", naive_bayes.predict(X_thi))]).round(3).to_string(index=False))
# 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
ti_so = naive_bayes.feature_log_prob_[1] - naive_bayes.feature_log_prob_[0]
print("\n12 từ nghiêng về SPAM nhất:", list(tu_dien[np.argsort(ti_so)[-12:][::-1]]))
print("8 từ nghiêng về HAM nhất  :", list(tu_dien[np.argsort(ti_so)[:8]]))

# Tốc độ học: lặp 5 lần lấy thời gian nhỏ nhất cho công bằng
def thoi_gian_hoc(mo_hinh):
    return min((lambda t0: (mo_hinh.fit(X_hoc, y_hoc), time.perf_counter() - t0)[1])(time.perf_counter()) for _ in range(5))
print("\nThời gian học trên", X_hoc.shape[0], "tin:")
for ten, m in [("Naive Bayes", MultinomialNB()), ("Perceptron", Perceptron(random_state=RANDOM_STATE)),
               ("SVM", LinearSVC(random_state=RANDOM_STATE)), ("Logistic", LogisticRegression(max_iter=1000, class_weight="balanced"))]:
    print(f"  {ten:12s} {thoi_gian_hoc(m) * 1000:6.1f} ms")

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

📦 naive_bayes = mô hình đã học.

👉 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).


Phần 8 — Hội đồng (Voting): khi các chuyên gia bắt tay nhau

📍 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.

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:

  • hard voting: mỗi mô hình bỏ 1 phiếu spam / ham, theo số đông;
  • soft voting: lấy trung bình xác suất của các mô hình;
  • có trọng số: phiếu của mô hình đáng tin hơn được nhân lên (ví dụ ×2).

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.

8.1 Các mô hình có sai cùng chỗ không?

In [ ]:
cac_mo_hinh = {"Perceptron": perceptron, "SVM": svm, "Logistic": logistic, "Naive Bayes": naive_bayes}
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
sai = phieu.ne(y_thi.to_numpy(), axis=0)                                               # True ở chỗ mô hình đó đoán sai
print("Số tin mỗi mô hình đoán SAI:", sai.sum().to_dict())
print("Số tin mà các mô hình KHÔNG thống nhất:", int((phieu.nunique(axis=1) > 1).sum()), "/", len(phieu))
print("Số tin mà CẢ 4 cùng sai:", int(sai.all(axis=1).sum()))

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

📦 phieu = bảng phiếu của 4 mô hình cho 1.293 tin · sai = True ở chỗ mô hình đoán sai.

👉 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ù.

8.2 So sánh các kiểu hội đồng

In [ ]:
from sklearn.ensemble import VotingClassifier
from sklearn.calibration import CalibratedClassifierCV
# ✏️ SỬA: trọng số phiếu [SVM, Logistic, Naive Bayes]. Thử [1, 1, 1], [1, 2, 1], [3, 1, 1]
TRONG_SO = [2, 1, 1]
def hoi_dong(kieu, trong_so=None):
    thanh_vien = [("svm", CalibratedClassifierCV(LinearSVC(random_state=RANDOM_STATE), cv=5) if kieu == "soft" else LinearSVC(random_state=RANDOM_STATE)),
                  ("logistic", LogisticRegression(max_iter=1000, class_weight="balanced")),
                  ("naive_bayes", MultinomialNB())]
    return VotingClassifier(thanh_vien, voting=kieu, weights=trong_so).fit(X_hoc, y_hoc)

hd_hard, hd_soft, hd_trong_so = hoi_dong("hard"), hoi_dong("soft"), hoi_dong("soft", TRONG_SO)
# 📦 hd_trong_so = hội đồng soft voting có trọng số (dùng ở Phần 9, 10)
bang_kq = pd.DataFrame([do_luong(ten, m.predict(X_thi)) for ten, m in
                        [("SVM (một mình)", svm), ("Hội đồng hard", hd_hard), ("Hội đồng soft, phiếu đều", hd_soft),
                         (f"Hội đồng soft, trọng số {TRONG_SO}", hd_trong_so)]])
bang_kq.round(3).set_index("mô hình")

Kết quả:

                                   accuracy  precision  recall     F1  chặn nhầm (FP)  bỏ lọt (FN)
SVM (một mình)                        0.982      0.979   0.877  0.926               3           20
Hội đồng hard                         0.982      0.979   0.877  0.926               3           20
Hội đồng soft, phiếu đều              0.981      0.973   0.877  0.923               4           20
Hội đồng soft, trọng số [2, 1, 1]     0.984      0.973   0.896  0.933               4           17

📦 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.

👉 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].


Phần 9 — Đánh giá: tin con số, không tin cảm giác

📍 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.

9.1 Bảng tổng hợp mọi cách — kể cả "lười" và luật cứng

In [ ]:
tong_hop = pd.DataFrame([
    do_luong("Đoán mọi tin là ham ('lười')", np.zeros(len(y_thi), dtype=int)),
    do_luong(f"Luật {len(TU_KHOA)} từ khóa (Phần 1)", bi_chan[vi_tri_thi].to_numpy()),
    do_luong("Perceptron", perceptron.predict(X_thi)),
    do_luong("SVM", svm.predict(X_thi)),
    do_luong("Logistic (balanced)", logistic.predict(X_thi)),
    do_luong("Naive Bayes", naive_bayes.predict(X_thi)),
    do_luong(f"Hội đồng trọng số {TRONG_SO}", hd_trong_so.predict(X_thi)),
])
tong_hop.round(3).set_index("mô hình")

Kết quả:

                              accuracy  precision  recall     F1  chặn nhầm (FP)  bỏ lọt (FN)
Đoán mọi tin là ham ('lười')     0.874      0.000   0.000  0.000               0          163
Luật 10 từ khóa (Phần 1)         0.919      0.693   0.638  0.665              46           59
Perceptron                       0.976      0.912   0.896  0.904              14           17
SVM                              0.982      0.979   0.877  0.926               3           20
Logistic (balanced)              0.978      0.904   0.926  0.915              16           12
Naive Bayes                      0.948      1.000   0.589  0.741               0           67
Hội đồng trọng số [2, 1, 1]      0.984      0.973   0.896  0.933               4           17

👉 Hiểu:

  • 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.
  • 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.

9.2 Ma trận nhầm lẫn của hội đồng

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).

In [ ]:
from sklearn.metrics import ConfusionMatrixDisplay
mt = confusion_matrix(y_thi, hd_trong_so.predict(X_thi))
ConfusionMatrixDisplay(mt, display_labels=["ham", "spam"]).plot(cmap="Blues")
plt.title("Hội đồng có trọng số — hàng: sự thật, cột: máy đoán"); plt.show()
tn, fp, fn, tp = mt.ravel()
print(f"Cho qua đúng (TN) {tn} | CHẶN NHẦM (FP) {fp} | BỎ LỌT (FN) {fn} | bắt đúng (TP) {tp}")
print(f"precision = TP / (TP + FP) = {tp} / {tp + fp} = {tp / (tp + fp):.3f}")
print(f"recall    = TP / (TP + FN) = {tp} / {tp + fn} = {tp / (tp + fn):.3f}")
print("\n3 tin RÁC bị bỏ lọt:")
du_doan_hd = hd_trong_so.predict(X_thi)
print(tin.noi_dung[vi_tri_thi][(y_thi == 1).to_numpy() & (du_doan_hd == 0)].head(3).to_string())
print("\nTin THẬT bị chặn nhầm:")
print(tin.noi_dung[vi_tri_thi][(y_thi == 0).to_numpy() & (du_doan_hd == 1)].head(4).to_string())

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...

👉 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).

9.3 Ngưỡng: đổi chặn nhầm lấy bỏ lọt

In [ ]:
# ✏️ SỬA: các ngưỡng muốn thử (trên xác suất của Logistic ở 6.3)
CAC_NGUONG = [0.9, 0.7, 0.5, 0.3, 0.1]
dong = []
for ng in CAC_NGUONG:
    p = (xac_suat >= ng).astype(int)
    tn_, fp_, fn_, tp_ = confusion_matrix(y_thi, p).ravel()
    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_})
bang_nguong = pd.DataFrame(dong).set_index("ngưỡng")
bang_nguong[["precision", "recall"]].plot(marker="o", figsize=(7, 3), title="Hạ ngưỡng: recall tăng, precision giảm")
plt.gca().invert_xaxis(); plt.show()
bang_nguong.round(3)

Kết quả: biểu đồ 2 đường và bảng:

ngưỡng precision recall chặn nhầm bỏ lọt
0.9 0.989 0.558 1 72
0.7 0.971 0.828 4 28
0.5 0.904 0.926 16 12
0.3 0.668 0.951 77 8
0.1 0.221 1.000 576 0

📦 bang_nguong = bảng trê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.


Phần 10 — AI đối đầu AI: kẻ tấn công chèn "từ an toà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ó.

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.

10.1 Tấn công

In [ ]:
# ✏️ SỬA: đoạn "từ an toàn" kẻ tấn công chèn vào cuối tin rác
CAU_CHEN = " ok see you later at home lor going to sleep now love you dear"
tin_rac_thi = tin.noi_dung[vi_tri_thi][(y_thi == 1).to_numpy()]
X_goc, X_bi_chen = tfidf.transform(tin_rac_thi), tfidf.transform(tin_rac_thi + CAU_CHEN)
dong = []
for ten, m in list(cac_mo_hinh.items()) + [(f"Hội đồng trọng số {TRONG_SO}", hd_trong_so)]:
    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()})
tan_cong = pd.DataFrame(dong).set_index("mô hình")
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)")
plt.xlabel("tỉ lệ bắt được"); plt.show()
print("Ví dụ tin rác sau khi bị chèn:", repr(tin_rac_thi.iloc[0] + CAU_CHEN))
tan_cong.round(3)

Kết quả: biểu đồ thanh và bảng (tỉ lệ trong 163 tin rác của đề thi bị bắt):

mô hình TRƯỚC khi chèn SAU khi chèn
Perceptron 0.896 0.503
SVM 0.877 0.675
Logistic 0.926 0.712
Naive Bayes 0.589 0.031
Hội đồng trọng số [2, 1, 1] 0.896 0.656

📦 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.

👉 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.

10.2 Phòng thủ: dạy lại máy bằng chính chiêu của kẻ tấn công

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.

Để 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).

In [ ]:
# ✏️ 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)
CAU_TAP = " hey dear how was your day see you at dinner tonight ok"
tin_rac_hoc = tin.noi_dung[vi_tri_hoc][(y_hoc == 1).to_numpy()]
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
nhan_moi = pd.concat([y_hoc, pd.Series(1, index=tin_rac_hoc.index)])                  # tất cả bản thêm đều là spam
tfidf_dk = TfidfVectorizer()
X_hoc_dk = tfidf_dk.fit_transform(noi_dung_moi)
dong = []
for ten, m in [("Naive Bayes", MultinomialNB()), ("SVM", LinearSVC(random_state=RANDOM_STATE)),
               ("Logistic", LogisticRegression(max_iter=1000, class_weight="balanced"))]:
    m.fit(X_hoc_dk, nhan_moi)
    p_thi = m.predict(tfidf_dk.transform(tin.noi_dung[vi_tri_thi]))
    dong.append({"mô hình (học lại)": ten,
                 "bắt tin rác bị chèn (câu 10.1)": m.predict(tfidf_dk.transform(tin_rac_thi + CAU_CHEN)).mean(),
                 "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())})
pd.DataFrame(dong).round(3).set_index("mô hình (học lại)")

Kết quả:

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
Naive Bayes 0.460 (trước: 0.031) 0.861 5
SVM 0.804 (trước: 0.675) 0.922 3
Logistic 0.834 (trước: 0.712) 0.901 14

📦 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.

👉 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.


Phần 11 — Tổng kết

📍 Bước 7 — Quy tắc bàn tay phải: tổng kết, báo cáo.

Bài này đã đo được gì?

Các con số chính, theo đúng thứ tự pipeline:

  • 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.
  • 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.
  • 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).

11.0 Quy tắc bàn tay ✋ áp vào bài này

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:

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ì…
👍 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
☝️ 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ộ đề
✋ 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)
💍 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
🤙 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ừ

11.1 Tương lai và báo cáo

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.

In [ ]:
from pathlib import Path
THU_MUC_RA = Path.home() / "lab" / "out_lab4"
THU_MUC_RA.mkdir(parents=True, exist_ok=True)
hd = tong_hop.set_index("mô hình").loc[f"Hội đồng trọng số {TRONG_SO}"]
luat = tong_hop.set_index("mô hình").loc[f"Luật {len(TU_KHOA)} từ khóa (Phần 1)"]
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)
=====================================================================================
Đề thi                 : {len(y_thi)} tin, {int(y_thi.sum())} tin rác
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)'])}
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)'])}
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

Khuyến nghị:
- 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.
- 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.
- Luôn bỏ bản sao trước khi chia học / thi; luôn so với mốc "lười".
- 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).
'''
(THU_MUC_RA / "bao_cao_lab4.txt").write_text(bao_cao, encoding="utf-8")
print(bao_cao)

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ị.

👉 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.

Câu hỏi suy nghĩ:

  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?
  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?
  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?
  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?