Bag of Words
Bag of Words - Túi từ
Hai câu "Tôi yêu mèo" và "Mèo yêu tôi" — nếu máy tính CHỈ ĐẾM TỪ (bỏ qua thứ tự), chúng có vector giống hay khác nhau?
Tưởng tượng bạn đưa đầu bếp một cái túi chứa nguyên liệu: 2 củ hành, 5 lát thịt bò, 3 cọng rau thơm, 1 nắm bánh phở. Đầu bếp biết đây là nguyên liệu nấu phở — nhưng không biết thứ tự cho vào nồi, không biết ai nên xào trước, ai luộc sau. Biểu diễn Bag of Words y hệt: bạn có danh sách đếm các từ, đủ để đoán chủ đề, nhưng không đủ để hiểu ngữ nghĩa trọn vẹn.
Hình minh họa
Vocabulary Builder — Biến văn bản thành vector
3 tài liệu mẫu tiếng Việt bên dưới. Bấm Xây dựng từ vựng để thấy BoW hoạt động từng bước: tokenize → build vocab → vector hóa → so sánh cosine matrix giữa 3 tài liệu.
Bag of Words biến văn bản thành vector số bằng cách đếm tần suất từ — đơn giản đến kinh ngạc, nhưng đủ mạnh để spam filter, phân loại tin tức, và phát hiện sentiment trong hàng thập kỷ trước khi deep learning xuất hiện.
Giống như xáo trộn nguyên liệu nấu phở trong một cái túi: bạn biết có bao nhiêu miếng thịt, hành, bánh phở — nhưng không biết thứ tự cho vào nồi. Đủ để nhận ra là phở, không đủ để nấu ngon.
Công thức & quy trình
Cho một corpus D = {d₁, d₂, …, d_N} gồm N tài liệu, Bag of Words biểu diễn mỗi tài liệu d thành một vector đếm theo công thức:
Trong đó V = |vocab| và c(wᵢ, d) là số lần từ wᵢ xuất hiện trong d. Toàn bộ corpus tạo thành một ma trận X ∈ ℝ^(N × V), thường rất thưa (> 99% số 0).
- Tokenize: chia văn bản thành từ (với tiếng Việt thường dùng underthesea, VnCoreNLP, hoặc PyVi để tách từ ghép).
- Build vocabulary:duyệt toàn bộ corpus, thu thập danh sách từ duy nhất. Có thể lọc stopword, từ < 2 lần xuất hiện, v.v.
- Vectorize: với mỗi tài liệu, đếm số lần mỗi từ trong vocab xuất hiện → vector V chiều.
- So sánh: dùng cosine similarity, Euclidean distance hoặc đưa vào classifier (Naive Bayes, Logistic Regression, SVM).
- Tách từ ghép (word segmentation): "học sinh" phải là 1 token chứ không phải 2. Dùng
underthesea.word_tokenize. - Chuẩn hóa dấu: Unicode tổ hợp ("hò") vs dựng sẵn ("hò") có cùng hình thức nhưng khác byte → cần NFC.
- Lowercase tất cả (hoặc không, tùy task). Loại stopword tiếng Việt ("là", "của", "và", …) nếu task không phụ thuộc.
- Xem xét lemmatization — nhưng tiếng Việt gần như không biến âm nên ít cần thiết, khác với tiếng Anh.
- Mất thứ tự:"phim này không hay" và "phim này hay không?" có cùng vector → không phân biệt được negation và câu hỏi.
- Không ngữ nghĩa:"phở" và "bún" là từ rời rạc, cosine = 0 dù cùng là món ăn. Cần word embeddings (word2vec, GloVe) hay mô hình ngôn ngữ để có "gần nghĩa".
- Out-of-vocabulary (OOV): từ mới chưa thấy trong tập huấn luyện sẽ bị bỏ qua hoàn toàn → mô hình thiếu thông tin quan trọng.
"Phim này không hay" — BoW xếp câu này vào nhóm tích cực hay tiêu cực nếu mô hình được huấn luyện rằng "hay" = tích cực? (Gợi ý: BoW đếm từ riêng lẻ)
Bạn có corpus 100.000 tài liệu, từ vựng sau lọc là 50.000. Ma trận BoW dense (không sparse) cần bao nhiêu bộ nhớ (giả sử float32 = 4 byte)?
Giải thích
Định nghĩa và lịch sử: từ Harris đến sklearn
Bag of Words (BoW) là phương pháp biểu diễn văn bản dưới dạng vector đếm tần suất của từng từ trong một từ vựng cố định V, bỏ qua hoàn toàn thứ tự xuất hiện. Đây là baseline điển hìnhvà đồng thời là "cánh cửa" đưa văn bản vào thế giới toán học của Machine Learning.
Ý tưởng "bag of words" bắt nguồn từ bài báo Distributional Structure (1954) của nhà ngôn ngữ học Zellig Harris, với nguyên lý "các từ xuất hiện trong bối cảnh tương tự nhau có xu hướng cùng nghĩa". Đến thập niên 1970, BoW trở thành mô hình chuẩn trong Information Retrieval — hệ thống tìm kiếm SMART của Gerard Salton (1971) và sau đó TF-IDF (Spärck Jones, 1972) là các cột mốc lớn.
Ngày nay, mỗi khi bạn gọi sklearn.feature_extraction.text.CountVectorizer, bạn đang dùng thuật toán này gần như nguyên vẹn sau 70 năm. Dù các mô hình transformer đã vượt xa, BoW vẫn là baseline bắt buộc và cực kỳ cạnh tranh trong nhiều bài toán phân loại văn bản.
- Rất nhanh: tokenize + count = O(tổng số token). So với BERT thì nhanh hơn 10.000 lần.
- Không cần GPU: huấn luyện logistic regression trên vector BoW chạy trong vài giây trên CPU.
- Dễ diễn giải: mỗi feature = 1 từ cụ thể. Nhìn trọng số là biết model đang dựa vào từ nào để quyết định — điều mà BERT không làm được.
- Đủ tốt cho nhiều bài toán: spam filter, phân loại tin tức, phát hiện ngôn từ thù hằn — BoW + logistic regression cho kết quả 85-95% accuracy.
Công thức toán học
Cho từ vựng , mỗi tài liệu d được biểu diễn bằng vector:
Trong đó là số lần từ xuất hiện trong d. Độ tương đồng giữa hai tài liệu dùng cosine:
Bước 1 — Tokenize:chia văn bản thành từ. Tiếng Anh split theo space là đủ; tiếng Việt cần tách từ ghép ("học sinh", "xe máy").
Bước 2 — Build vocabulary: V = tập hợp tất cả từ duy nhất trong corpus. Có thể áp dụng min_df=2 (loại từ xuất hiện < 2 tài liệu), max_df=0.95(loại từ xuất hiện > 95% tài liệu — quá phổ biến, không thông tin).
Bước 3 — Vectorize: với mỗi tài liệu, đếm số lần mỗi từ trong V xuất hiện. Kết quả là ma trận thưa N × |V|.
Bước 4 — Model: đưa ma trận vào classifier (Logistic Regression, Naive Bayes, SVM). Không cần feature engineering thêm — BoW đã là feature.
Cài đặt với scikit-learn (CodeBlock #1)
Đây là ví dụ đầy đủ: đọc corpus tiếng Việt, xây BoW, so sánh cosine similarity giữa query và 3 tài liệu.
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.metrics.pairwise import cosine_similarity
import numpy as np
# Corpus tiếng Việt (3 tài liệu)
docs = [
"phở ngon tuyệt vời phở rất ngon quán đẹp phục vụ nhanh",
"giao hàng nhanh sản phẩm tốt đóng gói đẹp rất hài lòng",
"dở tệ không ngon dịch vụ tệ phục vụ chậm không hài lòng",
]
# CountVectorizer xây BoW
vectorizer = CountVectorizer(
lowercase=True,
token_pattern=r"\S+", # tiếng Việt đã tokenize sẵn
min_df=1,
# ngram_range=(1, 2), # bật bigram để bắt 'không_tốt'
)
X = vectorizer.fit_transform(docs) # sparse matrix (3, |V|)
print("Kích thước vocab:", len(vectorizer.vocabulary_))
print("Shape:", X.shape, "| Sparsity:", 1 - X.nnz / np.prod(X.shape))
vocab = vectorizer.get_feature_names_out()
print("Vocab đầu:", vocab[:15])
print(X.toarray()[:, :15])
# Query & cosine similarity
query = "phở ngon phục vụ nhanh"
q_vec = vectorizer.transform([query])
sims = cosine_similarity(q_vec, X).flatten()
ranking = np.argsort(sims)[::-1]
print("\n=== Xếp hạng ===")
for rank, idx in enumerate(ranking, 1):
print(f"#{rank} sim={sims[idx]:.3f} ← {docs[idx]}")
# Thử với bigram để bắt 'không tốt'
bigram_vec = CountVectorizer(token_pattern=r"\S+", ngram_range=(1, 2))
X_bigram = bigram_vec.fit_transform(docs)
print("\n|V| với bigram:", X_bigram.shape[1])Chạy đoạn code trên bạn sẽ thấy: query "phở ngon phục vụ nhanh" tương đồng cao nhất với D1 (review quán phở) như kỳ vọng. Nhưng D3 (review tệ, cũng có "phục vụ") vẫn có cosine > 0 vì chia sẻ một vài từ chung — đó là hạn chế kinh điển của BoW không hiểu ngữ nghĩa.
Cài đặt tay không thư viện (CodeBlock #2)
Để chắc rằng mình hiểu từng bước, sau đây là cài đặt thuần Python — chính là thuật toán mà demo phía trên đang chạy. Độ phức tạp O(N · L) với L là độ dài trung bình của mỗi tài liệu.
from collections import Counter
from math import sqrt
def tokenize(text): return text.lower().strip().split()
def build_vocab(docs):
vocab = set()
for d in docs: vocab.update(tokenize(d))
return sorted(vocab)
def vectorize(text, vocab):
counts = Counter(tokenize(text))
return [counts[w] for w in vocab]
def cosine(a, b):
dot = sum(x * y for x, y in zip(a, b))
na = sqrt(sum(x * x for x in a))
nb = sqrt(sum(y * y for y in b))
return 0.0 if na * nb == 0 else dot / (na * nb)
docs = [
"phở ngon tuyệt vời phở rất ngon quán đẹp phục vụ nhanh",
"giao hàng nhanh sản phẩm tốt đóng gói đẹp rất hài lòng",
"dở tệ không ngon dịch vụ tệ phục vụ chậm không hài lòng",
]
vocab = build_vocab(docs)
X = [vectorize(d, vocab) for d in docs]
qv = vectorize("phở ngon phục vụ nhanh", vocab)
for i, row in enumerate(X, 1):
print(f"D{i}: cos={cosine(qv, row):.3f}")
# Ma trận cosine NxN giữa các document
print("\nCosine matrix:")
for i, a in enumerate(X, 1):
print(f"D{i}: {[f'{cosine(a, b):.2f}' for b in X]}")Trên corpus này sklearn và cài tay cho kết quả giống nhau (vì tokenizer đơn giản). Khi chuyển sang corpus thật, sklearn thắng nhờ sparse matrix và tối ưu C-level — nhưng cài tay dạy bạn cách debug từng bước.
Dùng BoW khi:
- Corpus nhỏ-vừa (< 1 triệu tài liệu), task đơn giản.
- Cần baseline nhanh để benchmark so với mô hình phức tạp.
- Cần model nhẹ, triển khai trên edge / mobile không có GPU.
- Cần diễn giải được feature — ví dụ trong domain y tế, tài chính.
Bỏ qua BoW khi:
- Task cần hiểu ngữ cảnh / thứ tự: dịch máy, QA, sentiment phức tạp.
- Có nhiều dữ liệu và GPU — nên dùng BERT/transformer để đạt SOTA.
- Dữ liệu đa ngôn ngữ hoặc có từ mới — embeddings xử lý OOV tốt hơn.
- Quên lowercase:"Phở" và "phở" trở thành 2 feature khác nhau, vocab phình gấp đôi.
- Không fit_transform trên train, transform trên test: nếu
fitcả test → vocab có từ chỉ xuất hiện ở test → data leakage kinh điển. - Tách từ sai với tiếng Việt:"học sinh" bị tách làm 2 — "học" và "sinh" — mất toàn bộ nghĩa từ ghép.
- Không kiểm tra sparsity:nếu < 90% có nghĩa là vocab quá nhỏ hoặc data quá đặc — nên điều tra.
Ứng dụng thực tế
- Spam filter: Naive Bayes + BoW vẫn là backend chuẩn của nhiều hệ thống email filter — nhanh, rẻ, đủ tốt.
- Phân loại tin tức: Logistic Regression + BoW với bigram thường đạt 85-92% macro-F1 trên tiếng Việt 5 chủ đề.
- Phát hiện ngôn từ thù hằn (hate speech): TF-IDF + SVM vẫn là baseline cứng cho bài toán này ở Kaggle.
- Information Retrieval: Elasticsearch, Solr, Lucene đều dùng BoW + BM25 (một biến thể TF-IDF) để tính điểm.
- Feature extraction cho topic modeling: LDA (Latent Dirichlet Allocation) chạy trực tiếp trên ma trận BoW.
Những sai lầm điển hình (pitfalls)
- Dùng
MultinomialNBtrên BoW có giá trị âm — Naive Bayes yêu cầu counts ≥ 0. - Bỏ stopword quá tay trong sentiment analysis — "không" là stopword nhưng cực kỳ quan trọng cho phủ định.
- Để
max_featuresquá nhỏ với corpus lớn — mất hết từ có ý nghĩa. - So sánh model có vocab khác nhau bằng chỉ số tuyệt đối — cosine chỉ có ý nghĩa khi vocab giống nhau.
- Quên
dtype=np.float32: mặc định sklearn trả float64, tăng 2× bộ nhớ.
Từ BoW đến các mô hình hiện đại
BoW là tổ tiên của một cây phả hệ dài: TF-IDF (giảm trọng số từ phổ biến) → Word Embeddings (word2vec, GloVe — từ thành vector dày có nghĩa) → Attention → Transformer → BERT, GPT. Mỗi bước tiến đều giải quyết một hạn chế của BoW: TF-IDF giảm trọng số từ phổ biến, embeddings thêm ngữ nghĩa, transformer thêm ngữ cảnh và thứ tự.
Dù vậy, BoW vẫn xứng đáng là "bài học đầu tiên" của NLP: nó dạy bạn tư duy biến văn bản thành số, tư duy vector hóa, tư duy sparse matrix, và tư duy similarity. Mọi mô hình hiện đại đều kế thừa các ý tưởng này — chỉ là đổi cách đếm (count → embed → attend) và đổi không gian (sparse V-chiều → dense 768-chiều).
- BoW biến văn bản thành vector bằng cách ĐẾM TẦN SUẤT từ — bỏ hoàn toàn thứ tự (vì thế gọi là 'túi').
- Mỗi vector BoW có chiều = |V| (kích thước từ vựng) và thường rất thưa (sparse) — > 99% số 0 trong thực tế.
- Cosine similarity là metric chuẩn: cos(θ) = (A·B)/(‖A‖·‖B‖) — không bị ảnh hưởng bởi độ dài tài liệu.
- Ưu điểm: đơn giản, nhanh, diễn giải được — vẫn là baseline cạnh tranh cho phân loại văn bản (spam, sentiment).
- Nhược điểm kinh điển: mất thứ tự ('không hay' ≈ 'hay không'), không ngữ nghĩa ('phở' xa 'bún'), vấn đề OOV với từ mới.
- TF-IDF cải thiện BoW bằng cách giảm trọng số từ phổ biến; word embeddings và transformer là những bước tiến xa hơn.
Kiểm tra hiểu biết
Hai câu 'Tôi yêu mèo' và 'Mèo yêu tôi' có vector Bag of Words giống nhau không?