Một bộ phân loại nhận xét bắt đầu từ chuyện biểu diễn câu chữ thành số. Ta đi từ ký tự có dấu, qua luật gộp subword, tới một quy trình TF-IDF hoàn chỉnh. Ở mỗi bước, ta kiểm xem thông tin nào được giữ và thông tin nào đã bị bỏ mất.
Ví dụ 1: Cùng một chữ có dấu, hai cách lưu
Hai nhận xét đều hiện ra là “Học sinh học máy”. Nhưng một bản lưu chữ có dấu thành một ký tự, còn bản kia lưu chữ cái gốc và dấu thành hai ký tự rời. Ta muốn máy coi hai bản là một câu, mà vẫn phân biệt được “ma” với “má”.
import unicodedata
def normalize_text(text):
nfc = unicodedata.normalize("NFC", text)
return " ".join(nfc.lower().split())
composed = "Học sinh học máy"
decomposed = unicodedata.normalize("NFD", composed)
print("bằng nhau trước NFC:", composed == decomposed)
print("số mã Unicode:", len(composed), len(decomposed))
print("bằng nhau sau NFC:", normalize_text(composed) == normalize_text(decomposed))
print("split:", normalize_text(composed).split())
print("giữ khác biệt ma/má:", normalize_text("ma") != normalize_text("má"))
assert normalize_text(composed) == normalize_text(decomposed)
assert normalize_text("ma") != normalize_text("má")
bằng nhau trước NFC: False
số mã Unicode: 16 19
bằng nhau sau NFC: True
split: ['học', 'sinh', 'học', 'máy']
giữ khác biệt ma/má: True
Nhìn thì giống hệt, nhưng so bằng == thì hai chuỗi khác nhau, và một chuỗi dài 16 mã, chuỗi kia dài 19. Ba mã thừa chính là ba dấu được lưu rời. Chuẩn hóa NFC gộp mọi cách lưu tương đương về một dạng, nên sau đó hai câu bằng nhau. Dấu thanh vẫn được giữ, nên “ma” và “má” vẫn khác nhau.
Hàm còn đưa về chữ thường và gom các khoảng trắng thừa. Tách theo khoảng trắng cho ra bốn âm tiết. Nó chưa biết “học sinh” và “học máy” là hai từ ghép. Đây là chuyện riêng của tiếng Việt, cần nhớ khi chọn cách tách từ.
Ví dụ 2: Học hai luật BPE từ số lần xuất hiện
Kho văn bản có từ low xuất hiện ba lần, lower hai lần, và new một lần. BPE bắt đầu từ từng ký tự, rồi mỗi bước gộp cặp ký tự liền nhau xuất hiện nhiều nhất. Ta học hai bước. Khi hai cặp có cùng số lần, ta chọn theo thứ tự từ điển, để chạy lại lần nào cũng ra một kết quả.
from collections import Counter
word_counts = {"low": 3, "lower": 2, "new": 1}
pieces = {word: list(word) for word in word_counts}
def count_pairs(pieces, frequencies):
counts = Counter()
for word, tokens in pieces.items():
for pair in zip(tokens, tokens[1:]):
counts[pair] += frequencies[word]
return counts
def merge_pair(tokens, pair):
result, i = [], 0
while i < len(tokens):
if i + 1 < len(tokens) and tuple(tokens[i:i + 2]) == pair:
result.append(tokens[i] + tokens[i + 1])
i += 2 # Hai token vừa gộp không được dùng lại trong cùng một lượt.
else:
result.append(tokens[i])
i += 1
return result
weighted = count_pairs(pieces, word_counts)
unweighted = count_pairs(pieces, dict.fromkeys(word_counts, 1))
print("cặp l,o: có trọng số / đếm mỗi từ một lần:", weighted[("l", "o")], unweighted[("l", "o")])
rules = []
for step in range(2):
counts = count_pairs(pieces, word_counts)
pair = min(counts, key=lambda p: (-counts[p], p))
rules.append(pair)
print("bước", step + 1, "gộp", pair, "tần suất", counts[pair])
pieces = {word: merge_pair(tokens, pair) for word, tokens in pieces.items()}
print("sau hai bước:", pieces)
assert weighted[("l", "o")] == 3 + 2
assert merge_pair(list("aaa"), ("a", "a")) == ["aa", "a"]
cặp l,o: có trọng số / đếm mỗi từ một lần: 5 2
bước 1 gộp ('l', 'o') tần suất 5
bước 2 gộp ('lo', 'w') tần suất 5
sau hai bước: {'low': ['low'], 'lower': ['low', 'e', 'r'], 'new': ['n', 'e', 'w']}
Cặp l, o xuất hiện 5 lần: ba lần trong low và hai lần trong lower. Nếu quên nhân với số lần xuất hiện của từ, ta chỉ đếm được 2, tức số loại từ chứa cặp này. Bước một gộp l, o thành lo. Từ đó sinh ra cặp mới lo, w, cũng xuất hiện 5 lần, nên bước hai gộp nó thành low. Sau hai bước, low đã là một token trọn vẹn.
Dòng assert cuối kiểm một lỗi tinh vi. Với aaa và luật gộp a, a, kết quả phải là aa, a. Ký tự giữa đã được dùng trong lần gộp đầu, nên không được gộp lần nữa.
Giờ áp các luật đã học cho từ lowest, một từ chưa từng xuất hiện nguyên vẹn. Ta chỉ áp lần lượt các luật theo đúng thứ tự đã học, không đếm lại cặp trên từ mới.
def encode_with_rules(word, rules):
tokens = list(word)
for pair in rules:
tokens = merge_pair(tokens, pair)
return tokens
encoded = encode_with_rules("lowest", rules)
print("lowest:", encoded)
print("khôi phục đúng chữ:", "".join(encoded) == "lowest")
assert "".join(encoded) == "lowest"
lowest: ['low', 'e', 's', 't']
khôi phục đúng chữ: True
lowest được tách thành mảnh low đã học, cộng ba ký tự lẻ. Ghép các mảnh lại thì ra đúng từ gốc. Đây là điểm mạnh của subword: từ mới vẫn được biểu diễn bằng những mảnh đã biết.
Hàm này mới chỉ áp luật gộp, chưa đổi các mảnh thành ID. Hai ký tự s và t chưa từng có trong kho văn bản, nên một tokenizer đầy đủ còn phải quy định cách xử lý ký tự lạ. Đây là bản BPE tối giản trên ký tự. Tokenizer của các model ngôn ngữ lớn còn làm việc trên byte, và có thêm các token đặc biệt.
Ví dụ 3: Tính một hàng TF-IDF bằng tay
Train gồm ba câu: “hay dễ”, “hay rõ”, và “khó”. Ta tính IDF bằng tay và so với thư viện, kiểm bước chuẩn hóa độ dài vector, rồi thử một câu chỉ có từ lạ “robot”.
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
train = ["hay dễ", "hay rõ", "khó"]
vectorizer = TfidfVectorizer()
matrix = vectorizer.fit_transform(train)
columns = [vectorizer.vocabulary_[term] for term in ["hay", "dễ"]]
idf_hand = np.array([np.log(4 / 3) + 1, np.log(4 / 2) + 1])
unit_hand = idf_hand / np.linalg.norm(idf_hand)
print("cột hay/dễ, IDF:", vectorizer.idf_[columns].round(3).tolist())
print("hàng hay dễ:", matrix[0, columns].toarray().ravel().round(3).tolist())
unknown = vectorizer.transform(["robot"])
print("robot: số feature khác 0:", unknown.nnz)
assert np.allclose(vectorizer.idf_[columns], idf_hand)
assert np.allclose(matrix[0, columns].toarray().ravel(), unit_hand)
assert unknown.nnz == 0
cột hay/dễ, IDF: [1.288, 1.693]
hàng hay dễ: [0.605, 0.796]
robot: số feature khác 0: 0
scikit-learn tính IDF theo công thức , với n là số câu và df là số câu chứa từ đó. “Hay” xuất hiện trong hai câu nên có IDF . “Dễ” chỉ có trong một câu nên IDF cao hơn, 1.693. Từ càng hiếm, trọng số càng cao. Sau đó cả hàng được chia cho độ dài để thành vector dài 1, ra [0.605, 0.796].
Câu “robot” không có từ nào trong từ điển, nên vector của nó toàn 0. Một bộ phân loại vẫn trả về một nhãn cho vector này, dựa vào bias. Nhãn ấy không chứng tỏ model hiểu gì về câu.
Ví dụ 4: Phân loại nhận xét, và truy nguyên hai câu phủ định
Tập train có 12 nhận xét, nhãn 1 là khen, nhãn 0 là chê. Sáu câu kiểm tra có hai câu phủ định, kiểu câu chưa từng gặp trong train. Mục tiêu là tìm xem lỗi bắt nguồn từ đâu. Sáu câu thì quá ít để đánh giá chất lượng model.
import unicodedata
import numpy as np
from sklearn.pipeline import Pipeline
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import confusion_matrix
def normalize_text(text):
return " ".join(unicodedata.normalize("NFC", text).lower().split())
train_texts = [
"bài học hay dễ hiểu", "nội dung rõ ràng bổ ích",
"bài giảng tốt dễ theo dõi", "ví dụ hay hữu ích",
"khóa học thú vị", "lời giải rõ ràng",
"bài học khó hiểu nhàm chán", "nội dung kém rối",
"bài giảng tệ khó theo dõi", "ví dụ sai vô ích",
"khóa học nhàm chán", "lời giải rối sai",
]
labels = np.array([1] * 6 + [0] * 6)
valid_texts = ["bài học hay", "lời giải sai", "bài giảng không hay",
"ví dụ rõ ràng", "nội dung rối", "khóa học không tệ"]
gold = np.array([1, 0, 0, 1, 0, 1])
model = Pipeline([
("tfidf", TfidfVectorizer(preprocessor=normalize_text, ngram_range=(1, 2))),
("classifier", LogisticRegression(C=4, random_state=0, max_iter=1000)),
])
model.fit(train_texts, labels) # Từ điển, IDF và bộ phân loại đều chỉ học từ train.
fitted_idf = model["tfidf"].idf_.copy()
prediction = model.predict(valid_texts)
positive_column = list(model["classifier"].classes_).index(1)
probability = model.predict_proba(valid_texts)[:, positive_column]
print("đúng:", int((prediction == gold).sum()), "/", len(gold))
print("confusion matrix [0,1]:", confusion_matrix(gold, prediction, labels=[0, 1]).tolist())
for i in [2, 5]:
print(f"thật={gold[i]} đoán={prediction[i]} p1={probability[i]:.3f} | {valid_texts[i]}")
assert np.array_equal(model["tfidf"].idf_, fitted_idf)
assert "không" not in model["tfidf"].vocabulary_
đúng: 4 / 6
confusion matrix [0,1]: [[2, 1], [1, 2]]
thật=0 đoán=1 p1=0.597 | bài giảng không hay
thật=1 đoán=0 p1=0.448 | khóa học không tệ
Model đúng 4 trên 6 câu, và sai đúng hai câu phủ định. “Bài giảng không hay” bị đoán là khen, “khóa học không tệ” bị đoán là chê. Con số p1 là xác suất model gán cho nhãn khen, không phải xác suất model hiểu đúng câu. Dòng assert cuối cho thấy nguyên nhân: từ “không” không hề có trong từ điển, vì train chưa có câu nào chứa nó.
Ta kiểm tiếp xem “không” có được biến thành số hay không. Sau đó thêm vào train bốn câu phủ định có nhãn rõ, rồi thử hai câu mới không có trong train mới. Lần sửa này là để kiểm một giả thuyết, sau khi đã đọc lỗi trên validation.
tfidf = model["tfidf"]
left = tfidf.transform(["bài giảng hay"])
right = tfidf.transform(["bài giảng không hay"])
print("hai vector giống nhau:", np.allclose(left.toarray(), right.toarray()))
has_bigram = tfidf.transform(["bài học hay"])
breaks_bigram = tfidf.transform(["bài học không hay"])
print("chèn không làm đứt bigram học hay:", not np.allclose(has_bigram.toarray(), breaks_bigram.toarray()))
assert np.allclose(left.toarray(), right.toarray())
assert not np.allclose(has_bigram.toarray(), breaks_bigram.toarray())
extra_texts = ["bài giảng không hay", "ví dụ không hay",
"bài học không tệ", "lời giải không tệ"]
extended = Pipeline([
("tfidf", TfidfVectorizer(preprocessor=normalize_text, ngram_range=(1, 2))),
("classifier", LogisticRegression(C=4, random_state=0, max_iter=1000)),
])
extended.fit(train_texts + extra_texts, np.r_[labels, [0, 0, 1, 1]])
probes = ["nội dung không hay", "khóa học không tệ"]
for text, pred in zip(probes, extended.predict(probes)):
print("sau bổ sung:", text, "->", int(pred))
print("đã học bigram không hay:", "không hay" in extended["tfidf"].vocabulary_)
assert "không hay" in extended["tfidf"].vocabulary_
hai vector giống nhau: True
chèn không làm đứt bigram học hay: True
sau bổ sung: nội dung không hay -> 0
sau bổ sung: khóa học không tệ -> 1
đã học bigram không hay: True
“Bài giảng hay” và “bài giảng không hay” cho ra đúng cùng một vector. Từ “không” bị bỏ qua vì không có trong từ điển. Cặp từ giảng hay cũng không có trong train, nên không cứu được. Với model này, hai câu ngược nghĩa nhau là một.
Trường hợp “bài học hay” thì khác. Cặp học hay có trong train, và chèn “không” vào giữa làm cặp ấy biến mất, nên vector đổi. Model chỉ đổi kết quả nhờ mất một cặp từ, chứ không phải nhờ hiểu phủ định.
Thêm bốn câu phủ định vào train, cặp không hay có cột riêng trong từ điển, và hai câu thử được đoán đúng. Nhưng hai câu thử thì chưa đủ để nói model đã khá hơn. Cần một tập kiểm tra mới, đa dạng hơn, chưa từng được dùng để sửa.