Files
labs/Notebooks-CSAI/Lab10_Model_Review_Improvement.ipynb

115 KiB

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

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. Cả notebook chạy hết khoảng 5–10 phút (Phần 5 và 6 lâu nhất).

Ở 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:

"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)?" "Chưa đạt kỳ vọng thì làm gì: thêm dữ liệu? thêm đặc trưng? đổi mô hình?"

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

Học xong bài này, bạ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).
  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).
  3. Trả lời câu "FN = 0 được không?" bằng ngưỡng, đường ROC / PR và chi phí (Phần 2).
  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).
  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).
  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).

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

  • 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.
  • 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.
  • 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.
  • 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.
  • Thước đo: chi phí của chặn nhầm (FP) và bỏ lọt (FN), không chỉ accuracy.

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.

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.

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, có nhãn không? (bộ tin nhắn của Lab 4) Phần 0 (thẻ dữ liệu), 0.1
2. Làm sạch dữ liệu bỏ bản sao trước khi chia — y hệt Lab 4 0.1
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)
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
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)
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
7. Quy tắc bàn tay phải ✋** tổng hợp cả khoá, báo cáo Phần 9, 10

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

📖 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
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 = TP / (TP + FP)
recall trong các tin spam thật, máy bắt được bao nhiêu phần = TP / (TP + FN)
F1 một con số gộp precision và recall (cao khi cả hai cùng cao)
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)
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
ngưỡng (threshold) điểm cắt: tin có điểm > ngưỡng thì chặn. Đổi ngưỡng = đổi FP lấy FN
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
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)
quá khớp (overfitting) học thuộc phần học, làm bài mới kém

🐍 Cú pháp Python hay gặp trong bài

Viết Nghĩa
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ự
cross_val_score(ong, X, y, cv=..., scoring="f1") chạy kiểm định chéo, trả về mảng K điểm F1
mang.mean(), mang.std() trung bình, độ lệch chuẩn (độ dao động) của một mảng số
(diem > 0.5) so sánh cả mảng một lúc → mảng True/False; .sum() đếm số True
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
pd.DataFrame(danh_sach_dict) biến danh sách các dict (mỗi dict 1 dòng) thành bảng
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ố

Phần 0 — Thu thập dữ liệu và điểm xuất phát

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

Câu hỏi Trả lời cho dữ liệu bài này
Dữ liệu gì? văn bản: tin nhắn SMS tiếng Anh, mỗi tin một nhãn ham / spam
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
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)
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
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)

0.1 Dựng lại mô hình của Lab 4

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.

In [ ]:
import time
import warnings
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
%matplotlib inline
from sklearn.model_selection import train_test_split
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.svm import LinearSVC
pd.set_option("display.max_colwidth", 110)
pd.set_option("display.width", 200)
warnings.filterwarnings("ignore", category=UserWarning)   # ẩn cảnh báo kỹ thuật không ảnh hưởng kết quả
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)

du_lieu = pd.read_csv("/opt/csai/labs/day4_phishing/SMSSpamCollection", sep="\t", header=None,
                      names=["nhan", "noi_dung"], quoting=3, encoding="utf-8")
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)
y = (tin.nhan == "spam").astype(int)                                  # 1 = spam, 0 = ham
vi_tri_hoc, vi_tri_thi = train_test_split(tin.index, test_size=0.25, random_state=RANDOM_STATE, stratify=y)
van_ban_hoc, van_ban_thi = tin.noi_dung[vi_tri_hoc], tin.noi_dung[vi_tri_thi]
y_hoc, y_thi = y[vi_tri_hoc], y[vi_tri_thi]
# 📦 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
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)")

def cham_diem(y_that, du_doan):
    # Đế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
    y_that, du_doan = np.asarray(y_that), np.asarray(du_doan)
    tp = int(((du_doan == 1) & (y_that == 1)).sum())    # bắt đúng spam
    fp = int(((du_doan == 1) & (y_that == 0)).sum())    # chặn nhầm tin thật
    fn = int(((du_doan == 0) & (y_that == 1)).sum())    # bỏ lọt spam
    tn = int(((du_doan == 0) & (y_that == 0)).sum())    # cho qua đúng tin thật
    precision = tp / (tp + fp) if tp + fp else 0.0
    recall = tp / (tp + fn) if tp + fn else 0.0
    return {"TP": tp, "FP": fp, "FN": fn, "TN": tn, "precision": precision, "recall": recall,
            "F1": 2 * precision * recall / (precision + recall) if precision + recall else 0.0,
            "FPR": fp / (fp + tn), "FNR": fn / (fn + tp), "accuracy": (tp + tn) / len(y_that)}

tfidf_goc = TfidfVectorizer()                           # từ điển từ đơn, như Lab 4
X_hoc_goc = tfidf_goc.fit_transform(van_ban_hoc)        # fit = học từ điển từ phần học
X_thi_goc = tfidf_goc.transform(van_ban_thi)            # phần thi chỉ transform
svm_goc = LinearSVC(random_state=RANDOM_STATE).fit(X_hoc_goc, y_hoc)
du_doan_goc = svm_goc.predict(X_thi_goc)
kq_goc = cham_diem(y_thi, du_doan_goc)
# 📦 kq_goc = dict mọi chỉ số của mô hình gốc trên phần thi
print(pd.DataFrame([[kq_goc["TN"], kq_goc["FP"]], [kq_goc["FN"], kq_goc["TP"]]],
                   index=["thật là ham", "thật là spam"], columns=["máy cho qua", "máy chặn"]))
print({k: round(v, 4) for k, v in kq_goc.items()})

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:

              máy cho qua  máy chặn
thật là ham          1127         3
thật là spam           20       143

và precision 0.9795 · recall 0.8773 · F1 0.9256 · FPR 0.0027 · FNR 0.1227 · accuracy 0.9822.

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

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

0.2 Cái bẫy "accuracy cao" và phép nhân với quy mô thật

📍 Bước 6 — Đánh giá: điểm xuất phát tốt đến đâu, khi nhân lên quy mô thật?

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.

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

In [ ]:
# ✏️ SỬA: quy mô của tổ chức bạn và tỉ lệ tin rác thực tế
SO_TIN_MOI_NGAY = 100_000
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

acc_luoi = (y_thi == 0).mean()      # mô hình lười: luôn đoán 0 (ham)
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")
print(f"SVM của Lab 4            : accuracy {kq_goc['accuracy']:.1%}, bắt được {kq_goc['TP']} / {y_thi.sum()} tin rác")

tin_that_ngay = SO_TIN_MOI_NGAY * (1 - TI_LE_SPAM_THUC_TE)
tin_rac_ngay = SO_TIN_MOI_NGAY * TI_LE_SPAM_THUC_TE
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")
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")
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")

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

👉 Hiểu:

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

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

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

Chỉ số Trả lời câu hỏi Công thức dễ nhớ Khi nào ưu tiê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)
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
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
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

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

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

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

In [ ]:
# ✏️ SỬA: kịch bản quét file — đổi số rồi chạy lại ô này
SO_FILE = 1_000_000      # số file quét mỗi ngày
SO_MALWARE = 100         # trong đó thật sự là mã độc
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
BAO_NHAM = 0             # mô hình cảnh báo nhầm bao nhiêu file sạch

y_file = np.zeros(SO_FILE, dtype=int)
y_file[:SO_MALWARE] = 1                                  # nhãn thật: SO_MALWARE file đầu là mã độc (1), còn lại sạch (0)
du_doan_file = np.zeros(SO_FILE, dtype=int)              # bắt đầu: máy nói "sạch" cho mọi file
du_doan_file[:BAT_DUOC] = 1                              # máy bắt đúng BAT_DUOC file mã độc
du_doan_file[SO_MALWARE:SO_MALWARE + BAO_NHAM] = 1       # máy cảnh báo nhầm BAO_NHAM file sạch
kq_file = cham_diem(y_file, du_doan_file)                # dùng lại hàm chấm điểm của 0.1
# 📦 kq_file = dict TP / FP / FN / TN + các chỉ số của kịch bản này
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']:,}")
print(f"Accuracy  = {kq_file['accuracy']:.2%}")
print(f"Precision = {kq_file['precision']:.2%}   (0 khi máy không cảnh báo file nào)")
print(f"Recall    = {kq_file['recall']:.2%}   -> tóm được {kq_file['TP']} / {SO_MALWARE} mã độc")
print(f"F1        = {kq_file['F1']:.3f}")

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

👉 Hiểu:

  • 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.
  • Precision ở đây là 0 vì máy không cảnh báo file nào (0 / 0, quy ước tính là 0).

✏️ Thử sửa (đã đo trên máy lớp):

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

🗣️ Câu thần chú — đọc to một lần, nhớ cả khóa:

  • Accuracy: "Đừng để con số 99% đánh lừa khi dữ liệu lệch!"
  • Precision: "Báo phát nào, trúng phát nấy (không báo ảo)."
  • Recall: "Thà bắt nhầm còn hơn bỏ sót (không lọt lưới)."
  • F1: "Trọng tài công tâm giữa Báo nhầm (Precision) và Bỏ sót (Recall)."

0.2c 🔧 Chỉ số nào kém → phải sửa gì? (bản đồ cải thiệ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ò.

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.

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

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

0.3 "Chưa đạt kỳ vọng" — bản đồ các việc có thể làm

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

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:

Thứ tự việc Câu hỏi Phầ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
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
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
4. Thêm đặc trưng cho máy "nhìn" thấy thứ con người nhìn thấy 4
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
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
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

Phần 1 — Đo cho chắc

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

1.1 Chỉ đổi cách chia, điểm thay đổi bao nhiêu?

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

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

In [ ]:
from sklearn.pipeline import make_pipeline
# ✏️ SỬA: số lần chia thử
SO_LAN_CHIA = 10
f1_moi_lan_chia = []                                   # 📦 danh sách F1, mỗi lần chia 1 số
for hat_giong in range(SO_LAN_CHIA):
    vt_hoc_k, vt_thi_k = train_test_split(tin.index, test_size=0.25, random_state=hat_giong, stratify=y)
    ong_k = make_pipeline(TfidfVectorizer(), LinearSVC(random_state=RANDOM_STATE)).fit(tin.noi_dung[vt_hoc_k], y[vt_hoc_k])
    f1_lan_nay = cham_diem(y[vt_thi_k], ong_k.predict(tin.noi_dung[vt_thi_k]))["F1"]
    f1_moi_lan_chia.append(f1_lan_nay)
    print(f"hạt giống {hat_giong}: F1 = {f1_lan_nay:.3f}")
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}")

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

📦 f1_moi_lan_chia = 10 điểm F1, mỗi cách chia một số.

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

1.2 Kiểm định chéo K phần (Stratified K-Fold)

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:

lần 1: [THI] [học] [học] [học] [học]
lần 2: [học] [THI] [học] [học] [học]
 ...
lần 5: [học] [học] [học] [học] [THI]

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

In [ ]:
from sklearn.model_selection import StratifiedKFold, cross_val_score
chia_5_phan = StratifiedKFold(n_splits=5, shuffle=True, random_state=RANDOM_STATE)   # cách chia, dùng lại cả bài
ong_goc = make_pipeline(TfidfVectorizer(), LinearSVC(random_state=RANDOM_STATE))      # đúng mô hình 0.1, dạng ống
f1_5_phan = cross_val_score(ong_goc, van_ban_hoc, y_hoc, cv=chia_5_phan, scoring="f1")
# 📦 f1_5_phan = mảng 5 điểm F1, mỗi phần 1 điểm
print("F1 từng phần:", np.round(f1_5_phan, 3))
print(f"F1 kiểm định chéo = {f1_5_phan.mean():.3f} ± {f1_5_phan.std():.3f}")

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

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

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


Phần 2 — "Làm sao cho FN = 0?"

📍 Bước 6 — Đánh giá: ngưỡng, đường ROC / PR, chi phí.

2.1 Mô hình không trả lời "có / không" — nó cho điểm

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.

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?

In [ ]:
diem_thi_goc = svm_goc.decision_function(X_thi_goc)          # 📦 điểm của 1.293 tin phần thi
y_thi_mang = y_thi.to_numpy()
print("Điểm 3 tin đầu phần thi:", np.round(diem_thi_goc[:3], 2), "| nhãn thật:", y_thi_mang[:3])

nguong_fn0 = diem_thi_goc[y_thi_mang == 1].min()             # điểm của tin rác thấp điểm nhất
nguong_fp0 = diem_thi_goc[y_thi_mang == 0].max()             # điểm của tin thật cao điểm nhất
# ✏️ SỬA: thêm ngưỡng bạn muốn thử
NGUONG_THU = [0.0, -0.5, nguong_fn0, nguong_fp0 + 1e-6]
dong_nguong = []
for nguong in NGUONG_THU:
    kq_n = cham_diem(y_thi_mang, (diem_thi_goc >= nguong).astype(int))
    dong_nguong.append({"ngưỡng": round(nguong, 3), "chặn nhầm (FP)": kq_n["FP"], "bỏ lọt (FN)": kq_n["FN"],
                        "FPR": round(kq_n["FPR"], 3), "precision": round(kq_n["precision"], 3), "recall": round(kq_n["recall"], 3)})
print(pd.DataFrame(dong_nguong).to_string(index=False))
# 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
vi_tri_rac_hien_nhat = int(np.where(y_thi_mang == 1, diem_thi_goc, np.inf).argmin())
vi_tri_that_giong_rac_nhat = int(np.where(y_thi_mang == 0, diem_thi_goc, -np.inf).argmax())
print(f"\nTin RÁC điểm thấp nhất ({nguong_fn0:.2f}):", repr(van_ban_thi.iloc[vi_tri_rac_hien_nhat]))
print(f"Tin THẬT điểm cao nhất ({nguong_fp0:.2f}):", repr(van_ban_thi.iloc[vi_tri_that_giong_rac_nhat]))

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:

 ngưỡng  chặn nhầm (FP)  bỏ lọt (FN)   FPR  precision  recall
  0.000               3           20 0.003      0.979   0.877
 -0.500              28            8 0.025      0.847   0.951
 -1.062             425            0 0.376      0.277   1.000
  0.238               0           26 0.000      1.000   0.840

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

📦 diem_thi_goc = 1.293 điểm · nguong_fn0, nguong_fp0 = hai ngưỡng "cực đoan".

👉 Hiểu — đây là câu trả lời cho "làm sao FN = 0":

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

2.2 Nhìn toàn bộ: hai đám điểm chồng lên nhau, đường ROC và đường PR

Biểu đồ Trục ngang Trục đứng Cách đọc
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
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)
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

Chấm đen = ngưỡng mặc định 0.

In [ ]:
from sklearn.metrics import roc_curve, roc_auc_score, precision_recall_curve
fpr_duong, tpr_duong, _ = roc_curve(y_thi_mang, diem_thi_goc)            # _ = danh sách ngưỡng, không cần dùng
pre_duong, rec_duong, _ = precision_recall_curve(y_thi_mang, diem_thi_goc)
auc_goc = roc_auc_score(y_thi_mang, diem_thi_goc)

hinh, (truc_a, truc_b, truc_c) = plt.subplots(1, 3, figsize=(15, 4))
cac_moc = np.linspace(diem_thi_goc.min(), diem_thi_goc.max(), 50)
truc_a.hist(diem_thi_goc[y_thi_mang == 0], bins=cac_moc, alpha=0.6, color="tab:blue", label="tin thật (ham)")
truc_a.hist(diem_thi_goc[y_thi_mang == 1], bins=cac_moc, alpha=0.6, color="tab:red", label="tin rác (spam)")
truc_a.axvline(0, color="k", ls="--", label="ngưỡng 0")
truc_a.axvline(nguong_fn0, color="tab:red", ls=":", label=f"ngưỡng FN=0 ({nguong_fn0:.2f})")
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)
truc_b.plot(fpr_duong, tpr_duong); truc_b.plot([0, 1], [0, 1], "k:", lw=0.8)
truc_b.scatter([kq_goc["FPR"]], [kq_goc["recall"]], color="k", zorder=3)
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}")
truc_c.plot(rec_duong, pre_duong); truc_c.scatter([kq_goc["recall"]], [kq_goc["precision"]], color="k", zorder=3)
truc_c.set_xlabel("recall"); truc_c.set_ylabel("precision"); truc_c.set_title("Precision – Recall")
plt.tight_layout(); plt.show()
print(f"AUC = {auc_goc:.4f}")

Kết quả: 3 biểu đồ, AUC = 0.9939.

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

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

2.3 Chọn ngưỡng theo chi phí — cách trả lời đúng cho câu "FN bao nhiêu là được?"

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:

tổng chi phí = CHI_PHI_FN × số FN + CHI_PHI_FP × số FP

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

In [ ]:
from sklearn.model_selection import cross_val_predict
diem_hoc_cv = cross_val_predict(ong_goc, van_ban_hoc, y_hoc, cv=chia_5_phan, method="decision_function")
# 📦 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
y_hoc_mang = y_hoc.to_numpy()
cac_nguong_thu = np.linspace(-2, 2, 401)                      # 401 ngưỡng từ -2 đến 2

def chon_nguong(ti_le_chi_phi):
    # 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
    chi_phi = [ti_le_chi_phi * ((diem_hoc_cv < t) & (y_hoc_mang == 1)).sum() + ((diem_hoc_cv >= t) & (y_hoc_mang == 0)).sum()
               for t in cac_nguong_thu]
    return cac_nguong_thu[int(np.argmin(chi_phi))]

# ✏️ SỬA: chi phí của bạn
CHI_PHI_FN = 10
CHI_PHI_FP = 1
dong_chi_phi = []
for ti_le in sorted({1, 10, 50, CHI_PHI_FN / CHI_PHI_FP}):
    nguong_tot = chon_nguong(ti_le)
    kq_cp = cham_diem(y_thi_mang, (diem_thi_goc >= nguong_tot).astype(int))
    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),
                         "PHẦN THI: chặn nhầm (FP)": kq_cp["FP"], "bỏ lọt (FN)": kq_cp["FN"], "F1": round(kq_cp["F1"], 3)})
bang_chi_phi = pd.DataFrame(dong_chi_phi)
bang_chi_phi

Kết quả:

 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
                    1                        -0.24                         8           11  0.941
                   10                        -0.48                        27            9  0.895
                   50                        -0.70                        74            5  0.800

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

👉 Hiểu:

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

2.4 Trả lời câu hỏi "làm sao cho FN = 0?"

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

Phần 3 — Đòn bẩy 1: thêm dữ liệu có giúp không?

📍 Bước 4 — Coding (cải thiện): thêm dữ liệu.

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

Đường Nghĩa
cam — điểm trên chính phần đã học mô hình "tự chấm bài đã học"
xanh — điểm kiểm định chéo điểm trên dữ liệu mới, thứ ta quan tâm
Đường xanh còn đang đi lên ở bên phải thêm dữ liệu còn giúp
Đườ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
Khoảng cách cam – xanh lớn mô hình học thuộc (quá khớp)
In [ ]:
from sklearn.model_selection import learning_curve
# ✏️ SỬA: các tỉ lệ dữ liệu học thử
TI_LE_DU_LIEU = [0.1, 0.25, 0.5, 0.75, 1.0]
so_tin_hoc, f1_tu_cham, f1_cv_lc = learning_curve(ong_goc, van_ban_hoc, y_hoc, cv=chia_5_phan, scoring="f1",
                                                  train_sizes=TI_LE_DU_LIEU)
# 📦 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
duong_cong = pd.DataFrame({"số tin học": so_tin_hoc, "F1 tự chấm": f1_tu_cham.mean(axis=1).round(3),
                           "F1 kiểm định chéo": f1_cv_lc.mean(axis=1).round(3), "± (độ lệch)": f1_cv_lc.std(axis=1).round(3)})
print(duong_cong.to_string(index=False))
plt.figure(figsize=(7, 4))
plt.plot(so_tin_hoc, f1_tu_cham.mean(axis=1), "o-", color="tab:orange", label="tự chấm bài đã học")
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")
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()

Kết quả:

 số tin học  F1 tự chấm  F1 kiểm định chéo  ± (độ lệch)
        310       1.000              0.812        0.043
        775       0.999              0.901        0.018
       1551       0.998              0.915        0.013
       2326       0.999              0.930        0.012
       3102       0.998              0.930        0.021

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.

📦 so_tin_hoc, f1_tu_cham, f1_cv_lc = kết quả learning_curve · duong_cong = bảng trên.

👉 Hiểu:

  • 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.
  • 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ì.
  • Đườ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).

Phần 4 — Đòn bẩy 2: thêm đặc trưng

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

4.1 Máy đang "nhìn" gì? Từ đơn, cặp từ, cụm ký tự

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:

Cách Tham số Ví dụ với "WIN £1000 cash"
từ đơn mặc định win, 1000, cash
từ đơn + cặp từ ngram_range=(1, 2) thêm win 1000, 1000 cash
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

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.

In [ ]:
import re
vi_du = "WIN £1000 cash! Call 09061701461 now"
print("từ đơn          :", TfidfVectorizer().build_analyzer()(vi_du))
print("từ đơn + cặp từ :", TfidfVectorizer(ngram_range=(1, 2)).build_analyzer()(vi_du)[-5:], "... (5 phần tử cuối)")
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)")

def dac_trung_so(chuoi_tin):
    # Nhận một cột tin nhắn (pandas Series), trả về mảng (số tin × 5) con số tự tính
    chuoi_tin = pd.Series(chuoi_tin)
    bang_so = pd.DataFrame({
        "do_dai": chuoi_tin.str.len(),                                                   # số ký tự
        "so_chu_so": chuoi_tin.str.count(r"\d"),                                        # \d = một chữ số
        "co_link": chuoi_tin.str.contains(r"http|www\.|\.com", case=False).astype(int),
        "co_day_5_so": chuoi_tin.str.contains(r"\d{5,}").astype(int),                    # ≥ 5 chữ số liền nhau
        "ti_le_hoa": chuoi_tin.str.count(r"[A-Z]") / chuoi_tin.str.len().clip(lower=1),  # clip: tránh chia cho 0
    })
    return bang_so.to_numpy(dtype=float)

TEN_SO_DO = ["do_dai", "so_chu_so", "co_link", "co_day_5_so", "ti_le_hoa"]
mau_2_tin = pd.Series(["Ok lar... Joking wif u oni...", vi_du])
print("\n5 số đo của 1 tin thật và 1 tin rác:")
print(pd.DataFrame(dac_trung_so(mau_2_tin), columns=TEN_SO_DO, index=mau_2_tin).round(2).to_string())

Kết quả:

từ đơn          : ['win', '1000', 'cash', 'call', '09061701461', 'now']
từ đơn + cặp từ : ['win 1000', '1000 cash', 'cash call', 'call 09061701461', '09061701461 now'] ... (5 phần tử cuối)
cụm ký tự 2–5   : [' w', 'wi', 'in', 'n ', ' wi', 'win', 'in ', ' win', 'win ', ' win ', ' £', '£1'] ... (12 phần tử đầu)

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.

📦 dac_trung_so(chuoi_tin) = hàm trả về 5 số đo cho mỗi tin · TEN_SO_DO = tên 5 cột.

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

4.2 So sánh 5 cách "nhìn" — cùng một mô hình SVM

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.

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.

In [ ]:
from sklearn.pipeline import FeatureUnion
from sklearn.preprocessing import FunctionTransformer, StandardScaler

def tao_svm():
    return LinearSVC(random_state=RANDOM_STATE)

so_do_chuan_hoa = make_pipeline(FunctionTransformer(dac_trung_so), StandardScaler())    # 5 số đo -> cùng thang
cac_cach_nhin = {
    "1. từ đơn (như Lab 4)": make_pipeline(TfidfVectorizer(), tao_svm()),
    "2. từ đơn + cặp từ": make_pipeline(TfidfVectorizer(ngram_range=(1, 2)), tao_svm()),
    "3. cụm ký tự 2-5": make_pipeline(TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 5)), tao_svm()),
    "4. từ + ký tự": make_pipeline(FeatureUnion([("tu", TfidfVectorizer()),
                                                ("ky_tu", TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 5)))]), tao_svm()),
    "5. từ + ký tự + 5 số đo": make_pipeline(FeatureUnion([("tu", TfidfVectorizer()),
                                                          ("ky_tu", TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 5))),
                                                          ("so_do", so_do_chuan_hoa)]), tao_svm()),
}
dong_dac_trung = []
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)
for ten_cach, ong_cach in cac_cach_nhin.items():
    f1_cv_cach = cross_val_score(ong_cach, van_ban_hoc, y_hoc, cv=chia_5_phan, scoring="f1")
    f1_tung_phan[ten_cach] = f1_cv_cach
    kq_cach = cham_diem(y_thi, ong_cach.fit(van_ban_hoc, y_hoc).predict(van_ban_thi))
    dong_dac_trung.append({"cách nhìn": ten_cach, "F1 kiểm định chéo": f1_cv_cach.mean(), "±": f1_cv_cach.std(),
                           "F1 phần thi": kq_cach["F1"], "chặn nhầm (FP)": kq_cach["FP"], "bỏ lọt (FN)": kq_cach["FN"]})
so_sanh_dac_trung = pd.DataFrame(dong_dac_trung).set_index("cách nhìn")
# 📦 so_sanh_dac_trung = bảng 5 cách nhìn × các chỉ số
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),
                                            title="F1 kiểm định chéo (vạch đen = ± độ lệch)")
plt.show()
print(so_sanh_dac_trung.round(3).to_string())

Kết quả: biểu đồ thanh và bảng:

                         F1 kiểm định chéo      ±  F1 phần thi  chặn nhầm (FP)  bỏ lọt (FN)
1. từ đơn (như Lab 4)                0.930  0.021        0.926               3           20
2. từ đơn + cặp từ                   0.934  0.016        0.936               4           16
3. cụm ký tự 2-5                     0.953  0.024        0.966               2            9
4. từ + ký tự                        0.955  0.020        0.956               2           12
5. từ + ký tự + 5 số đo              0.953  0.024        0.956               2           12

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

👉 Hiểu:

  • 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.
  • Cặp từ chỉ nhích nhẹ (0,930 → 0,934, nhỏ hơn cỡ ±).
  • 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.
  • Vạch ± ở biểu đồ vẫn chồng lên nhau → kiểm tra kỹ hơn ở 4.3.

4.3 "Tốt hơn thật" hay "may"? So theo từng cặp

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

In [ ]:
cach_moc = "1. từ đơn (như Lab 4)"
for ten_cach, f1_cach in f1_tung_phan.items():
    if ten_cach == cach_moc:
        continue
    chenh = f1_cach - f1_tung_phan[cach_moc]          # 📦 mảng 5 hiệu số, phần nào với phầ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")

Kết quả:

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

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


Phần 5 — Đòn bẩy 3: mô hình khác

📍 Bước 3 + 4 — Chọn mô hình và coding: 7 mô hình trên cùng dữ liệu.

5.1 Bảy mô hình trên cùng một dữ liệu (từ đơn TF-IDF)

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

Mô hình Ý tưởng một dòng
Naive Bayes đếm xác suất từng từ xuất hiện trong spam / ham, nhân lại
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
SVM tuyến tính ranh giới thẳng có vùng đệm rộng nhất
Cây quyết định chuỗi câu hỏi có / không
Rừng ngẫu nhiên 300 cây bỏ phiếu
XGBoost 300 cây nhỏ nối tiếp, cây sau sửa lỗi cây trước
Mạng nơ-ron (MLP) 1 lớp ẩn 64 nơ-ron
In [ ]:
from sklearn.model_selection import cross_validate
from sklearn.metrics import roc_auc_score
from sklearn.naive_bayes import MultinomialNB
from sklearn.linear_model import LogisticRegression
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.neural_network import MLPClassifier
from xgboost import XGBClassifier

# ✏️ 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
CHAY_MO_HINH_CHAM = True
cac_mo_hinh = {
    "Naive Bayes": (MultinomialNB(), "dễ: xác suất từng từ"),
    "Logistic Regression (balanced)": (LogisticRegression(max_iter=1000, class_weight="balanced"), "dễ: trọng số từng từ"),
    "SVM tuyến tính": (tao_svm(), "dễ: trọng số từng từ"),
    "Cây quyết định": (DecisionTreeClassifier(random_state=RANDOM_STATE), "dễ nếu cây nông; cây sâu thì khó"),
}
if CHAY_MO_HINH_CHAM:
    cac_mo_hinh.update({
        "Rừng ngẫu nhiên (300 cây)": (RandomForestClassifier(n_estimators=300, n_jobs=2, random_state=RANDOM_STATE), "khó: cần SHAP"),
        "XGBoost (300 cây)": (XGBClassifier(n_estimators=300, n_jobs=2, random_state=RANDOM_STATE, eval_metric="logloss"), "khó: cần SHAP"),
        "Mạng nơ-ron MLP (64)": (MLPClassifier(hidden_layer_sizes=(64,), max_iter=300, random_state=RANDOM_STATE), "rất khó"),
    })

def diem_lien_tuc(mo_hinh, X):
    # Đ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
    return mo_hinh.decision_function(X) if hasattr(mo_hinh, "decision_function") else mo_hinh.predict_proba(X)[:, 1]

dong_mo_hinh = []
for ten_mh, (mo_hinh, giai_thich) in cac_mo_hinh.items():
    ong_mh = make_pipeline(TfidfVectorizer(), mo_hinh)
    do_cv = cross_validate(ong_mh, van_ban_hoc, y_hoc, cv=chia_5_phan, scoring="f1")     # dict: test_score, fit_time...
    ong_mh.fit(van_ban_hoc, y_hoc)
    kq_mh = cham_diem(y_thi, ong_mh.predict(van_ban_thi))
    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(),
                         "F1 phần thi": kq_mh["F1"], "FP": kq_mh["FP"], "FN": kq_mh["FN"],
                         "AUC": roc_auc_score(y_thi, diem_lien_tuc(ong_mh, van_ban_thi)),
                         "giây học (5 lần)": do_cv["fit_time"].sum(), "giải thích được?": giai_thich})
    print(f"xong {ten_mh}")
so_sanh_mo_hinh = pd.DataFrame(dong_mo_hinh).set_index("mô hình")
# 📦 so_sanh_mo_hinh = bảng mô hình × chỉ số
so_sanh_mo_hinh.round(3)

Kết quả: (in xong ... sau mỗi mô hình), rồi:

                                F1 kiểm định chéo      ±  F1 phần thi  FP  FN    AUC  giây học (5 lần)
Naive Bayes                                 0.737  0.046        0.741   0  67  0.983             0.297
Logistic Regression (balanced)              0.911  0.019        0.915  16  12  0.989             0.561
SVM tuyến tính                              0.930  0.021        0.926   3  20  0.994             0.283
Cây quyết định                              0.798  0.041        0.854  14  31  0.899             1.148
Rừng ngẫu nhiên (300 cây)                   0.875  0.022        0.881   1  34  0.994             8.990
XGBoost (300 cây)                           0.850  0.047        0.872   4  34  0.966            17.512
Mạng nơ-ron MLP (64)                        0.926  0.025        0.923   4  20  0.993            34.712

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

👉 Hiểu:

  • 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.
  • 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.
  • 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ở.
  • 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.
  • Naive Bayes: FP 0 nhưng bỏ lọt 67 — cực "nhút nhát".
  • 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).

5.2 Đổi chính xác lấy tốc độ?

Trục Cách đọc
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)
đứng — F1 kiểm định chéo càng cao càng tốt
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
In [ ]:
plt.figure(figsize=(8, 4.5))
for ten_mh, dong_mh in so_sanh_mo_hinh.iterrows():                      # iterrows: lặp từng dòng (tên, dữ liệu)
    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)
    plt.annotate(ten_mh, (dong_mh["giây học (5 lần)"], dong_mh["F1 kiểm định chéo"]), fontsize=8,
                 xytext=(5, 3), textcoords="offset points")
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")
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()

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

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


Phần 6 — Đòn bẩy 4: tinh chỉnh tham số

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

6.1 GridSearchCV: thử mọi tổ hợp, chấm bằng kiểm định chéo

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úm Ý nghĩa Giá trị thử
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
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

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.

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.

In [ ]:
from sklearn.model_selection import GridSearchCV
ong_ky_tu = make_pipeline(TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 5)), tao_svm())
# ✏️ SỬA: lưới giá trị thử (thêm giá trị = lâu hơn)
LUOI_NUM = {"linearsvc__C": [0.1, 0.3, 1, 3, 10], "linearsvc__class_weight": [None, "balanced"]}
bat_dau = time.time()
tim_num = GridSearchCV(ong_ky_tu, LUOI_NUM, cv=chia_5_phan, scoring="f1", n_jobs=2).fit(van_ban_hoc, y_hoc)
print(f"Thử {len(tim_num.cv_results_['params'])} tổ hợp trong {time.time() - bat_dau:.0f} giây")
bang_luoi = pd.DataFrame(tim_num.cv_results_)[["param_linearsvc__C", "param_linearsvc__class_weight",
                                                "mean_test_score", "std_test_score"]]
bang_luoi.columns = ["C", "class_weight", "F1 kiểm định chéo", "±"]
print(bang_luoi.sort_values("F1 kiểm định chéo", ascending=False).round(3).to_string(index=False))
print("\nTổ hợp tốt nhất:", tim_num.best_params_, f"| F1 kiểm định chéo {tim_num.best_score_:.3f}")
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
kq_cuoi = cham_diem(y_thi, mo_hinh_cuoi.predict(van_ban_thi))
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']}"
      f"   (gốc Lab 4: F1 {kq_goc['F1']:.3f} | {kq_goc['FP']} | {kq_goc['FN']})")

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)

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

👉 Hiểu:

  • 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.
  • 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.
  • 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.
  • Phần thi chỉ được nhìn sau khi đã chọn xong, nên 0,966 là con số trung thực.

Phần 7 — Phân tích lỗi còn lại

📍 Bước 6 — Đánh giá: đọc từng lỗi còn lại.

7.1 Đọc từng tin máy vẫn sai

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

In [ ]:
diem_thi_cuoi = mo_hinh_cuoi.decision_function(van_ban_thi)          # 📦 điểm mô hình cuối cho 1.293 tin thi
du_doan_cuoi = mo_hinh_cuoi.predict(van_ban_thi)
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()})
print(f"BỎ LỌT (spam bị cho qua) — {kq_cuoi['FN']} tin, xếp từ điểm cao xuống:")
print(bang_loi[(y_thi_mang == 1) & (du_doan_cuoi == 0)].sort_values("điểm", ascending=False).to_string(index=False))
print(f"\nCHẶN NHẦM (tin thật bị chặn) — {kq_cuoi['FP']} tin:")
print(bang_loi[(y_thi_mang == 0) & (du_doan_cuoi == 1)].to_string(index=False))

Kết quả: 8 tin bỏ lọt (điểm từ −0,04 đến −0,66), ví dụ:

  • -0.04 Babe: U want me dont u baby! Im nasty ... Txt XXX SLO(4msgs) — sát ngưỡng
  • -0.16 ringtoneking 84484 · -0.28 RCT' THNQ Adrian for U text. Rgds Vatian — quá ngắ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!
  • -0.50 câu đố Simpsons (tin ở 2.1) · -0.65 Money i have won wining number 946 wot do i do next

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

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

👉 Hiểu — phân loại từng lỗi:

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

7.2 Không để máy quyết định một mình: phân luồng 3 là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.

Cột Nghĩa
vùng giữa tin có điểm trong khoảng [−độ rộng, +độ rộng] → người xem
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)
người xem / ngày quy ra quy mô SO_TIN_MOI_NGAY ở 0.2
In [ ]:
# ✏️ SỬA: các độ rộng vùng giữa thử
DO_RONG_VUNG_GIUA = [0.0, 0.25, 0.5, 0.75, 1.0]
dong_lan = []
for do_rong in DO_RONG_VUNG_GIUA:
    vung_giua = (diem_thi_cuoi > -do_rong) & (diem_thi_cuoi <= do_rong)
    dong_lan.append({"độ rộng": do_rong, "vùng giữa (tin)": int(vung_giua.sum()),
                     "chặn nhầm tự động": int(((diem_thi_cuoi > do_rong) & (y_thi_mang == 0)).sum()),
                     "bỏ lọt tự động": int(((diem_thi_cuoi <= -do_rong) & (y_thi_mang == 1)).sum()),
                     "người xem / ngày": round(vung_giua.mean() * SO_TIN_MOI_NGAY)})
ba_lan = pd.DataFrame(dong_lan)
ba_lan

Kết quả:

   độ rộng  vùng giữa (tin)  chặn nhầm tự động  bỏ lọt tự động  người xem / ngày
0     0.00                0                  3               8                 0
1     0.25               26                  2               4              2011
2     0.50               92                  0               3              7115
3     0.75              307                  0               0             23743
4     1.00              699                  0               0             54060

📦 ba_lan = bảng trên.

👉 Hiểu:

  • Vùng giữa ±0,25: chỉ 26 / 1.293 tin (2%) cho người xem → lỗi tự động còn 2 + 4.
  • ±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.
  • ±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á.
  • 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."

Phần 8 — Mô hình tốt hơn có khó lừa hơn không? Và dữ liệu trôi

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

8.1 Đòn "chèn từ an toàn" của Lab 4, thử lại trên mô hình cuối

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

In [ ]:
# ✏️ SỬA: câu kẻ tấn công chèn (giống Lab 4)
CAU_CHEN = " ok see you later at home lor going to sleep now love you dear"
tin_rac_phan_thi = van_ban_thi[y_thi_mang == 1]                      # 📦 các tin rác của phần thi
dong_tan_cong = []
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)
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)]:
    dong_tan_cong.append({"mô hình": ten_ong, "bắt được TRƯỚC": ong_tc.predict(tin_rac_phan_thi).mean(),
                          "bắt được SAU khi chèn": ong_tc.predict(tin_rac_phan_thi + CAU_CHEN).mean()})
pd.DataFrame(dong_tan_cong).set_index("mô hình").round(3)

Kết quả:

                                     bắt được TRƯỚC  bắt được SAU khi chèn
SVM gốc Lab 4 (từ đơn)                        0.877                  0.675
Mô hình cuối (ký tự, đã tinh chỉnh)           0.951                  0.908

📦 tin_rac_phan_thi = 163 tin rác của phần thi · ong_goc_da_hoc = ống gốc đã học lại.

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

8.2 Dữ liệu trôi — điểm hôm nay không phải điểm tháng sau

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:

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

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.

8.3 Khi nào phải học lại (retrain)? Theo dõi chỉ số nào?

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

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)

Nhóm Chỉ số theo dõi Cách đo Dấu hiệu có chuyệ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
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)
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
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
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)
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ả
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

B. Khi nào phải học lại?

Tình huống Học lại? Ví dụ
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
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%
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"
Đổ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 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ỳ
Đ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

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

  1. Dữ liệu học mới + sạch (bỏ bản sao, nhãn kiểm lại) — ngón cái ✋.
  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.
  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.

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


Phần 9 — Tổng hợp cả khóa

📍 Bước 7 — Quy tắc bàn tay phải: tổng hợp cả khoá.

9.1 Các mô hình đã xây trong từng notebook

Notebook Bài toán Dữ liệu thật Mô hình Kết quả đo Chỉ số nên ưu tiê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
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)
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
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í
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õ
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)
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

9.2 Chọn mô hình nào? Khi nào dùng gì, đánh đổi gì

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), Lab 4, bài này
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 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.
  • 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ị 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).

Tóm tắt theo tình huống (kèm núm cần chỉnh):

Tình huống Nên thử trước Vì sao Núm cần chỉnh
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
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
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
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)
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
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
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

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, Lab 3 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, Lab 4 Phần 7, bài này 5.1
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
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)
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
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
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
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 bài này 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

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.

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.

📌 Pipeline xử lý luôn là 7 bước (bảng chi tiết ở đầ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 ✋

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 đ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:

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ì…
👍 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
☝️ 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
✋ 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
💍 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
🤙 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

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:

Ngón Ôn / cải thiện / theo dõi
👍 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)
☝️ Đặ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)
✋ Chọn công cụ đổi mô hình sau khi chẩn đoán, không đổi mò (0.2c)
💍 Đ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)
🤙 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

9.3 "Chưa đạt kỳ vọng" — danh sách kiểm tra

  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.
  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).
  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.
  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).
  5. Đổi mô hình theo bảng 9.2, so trên cùng cách chia, có ± độ lệch.
  6. Tinh chỉnh bằng GridSearchCV trên phần học; phần thi dùng một lầ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ự.
  8. Đưa vào dùng có 3 làn + theo dõi trôi + học lại có so sánh.

9.4 Những điều bắt buộc phải chấp nhậ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í.
  • Đ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".
  • 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.
  • "Tốt" phụ thuộc định nghĩa nhãn (Lab 6: đổi nhãn là đổi thứ hạng mô hình).
  • 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.
  • 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).

9.5 Câu hỏi hay gặp

Câu hỏi Trả lời ngắ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).
"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.
"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.
"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.
"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.
"Thêm dữ liệu luôn tốt hơn?" Chỉ khi đường cong học còn đi lên (3.1).
"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).

9.6 Quy tắc bỏ túi — 4 chỉ số (nhắc lại 0.2b)

Chỉ số Mẫu số là gì? Câu thần chú
Accuracy tất cả mẫu "Đừng để con số 99% đánh lừa khi dữ liệu lệch!"
Precision những gì máy cảnh báo (TP + FP) "Báo phát nào, trúng phát nấy."
Recall những gì thật sự xấu (TP + FN) "Thà bắt nhầm còn hơn bỏ sót."
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."

Phần 10 — Báo cáo

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

In [ ]:
from pathlib import Path
THU_MUC_RA = Path.home() / "lab" / "out_lab10"
THU_MUC_RA.mkdir(parents=True, exist_ok=True)
tot_nhat_dac_trung = so_sanh_dac_trung["F1 kiểm định chéo"].idxmax()
tot_nhat_mo_hinh = so_sanh_mo_hinh["F1 kiểm định chéo"].idxmax()
nguong_chon = bang_chi_phi[bang_chi_phi["1 FN đắt bằng ... FP"] == CHI_PHI_FN / CHI_PHI_FP].iloc[0]
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)
=====================================================================================
Phần thi: {len(y_thi)} tin, {int(y_thi.sum())} tin rác
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}
Độ 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}
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())}
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)'])}
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
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})
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})
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']}

Khuyến nghị:
- Chọn ngưỡng theo chi phí FN/FP trên phần học, không đòi FN = 0.
- Đầ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.
- 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ũ.
'''
(THU_MUC_RA / "bao_cao_lab10.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_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ị.

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

Câu hỏi suy nghĩ:

  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?
  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?
  3. Ở 7.2, độ rộng nào cho số "người xem / ngày" mà đội của bạn đọc nổi?
  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?