Bỏ qua để đến nội dung

Nhận dạng thực thể (NER)

Sau bài này, bạn sẽ:

  • Hiểu nhận dạng thực thể có tên (named entity recognition, NER) là một bài toán gán nhãn chuỗi (sequence labeling), và vì sao chỉ tra từ điển tên là không đủ.
  • Mã hóa được thực thể bằng nhãn BIO, giải thích vì sao cần nhãn B-, và giải mã ngược nhãn thành các đoạn thực thể.
  • Giải thích được CRF ở mức ý tưởng, cách BERT gán nhãn cho từng token, và cách căn nhãn với subword bằng giá trị -100.
  • Tính được precision, recall và F1 mức thực thể, và nhận ra vì sao độ chính xác mức token làm điểm đẹp hơn thật.
  • BERT, thuộc phần Xử lý ngôn ngữ tự nhiên (Đã học)

Đọc câu sau:

Ông Nguyễn Văn An đến Hà Nội làm việc cho Vietcombank từ tháng 3/2024.

Bạn thấy ngay: “Nguyễn Văn An” là một người, “Hà Nội” là một nơi chốn, “Vietcombank” là một tổ chức, “tháng 3/2024” là một mốc thời gian. Những cụm gọi tên một đối tượng cụ thể như vậy là thực thể có tên (named entity). Tìm ra chúng, đúng ranh giới và đúng loại, là việc của NER.

Việc này nghe nhỏ nhưng nằm dưới rất nhiều hệ thống. Báo điện tử gắn thẻ người và công ty cho từng bài. Ngân hàng đọc hồ sơ để lấy tên, địa chỉ. Hệ thống hỏi đáp cần biết câu hỏi “ai” thì câu trả lời phải là một người.

Cách đầu tiên ai cũng nghĩ tới: lập một danh sách tên người, tên địa danh, tên công ty, rồi tìm trong câu. Danh sách như vậy gọi là gazetteer (từ điển tên). Nó hữu ích, nhưng vỡ ở ba chỗ:

  • Cùng một chuỗi, khác loại. “Hà Nội” trong “đến Hà Nội” là nơi chốn. Trong “Chị Mai chuyển từ Bia Hà Nội sang Vinamilk”, “Hà Nội” chỉ là một phần của tên công ty “Bia Hà Nội”. Danh sách không biết ngữ cảnh.
  • Tên trùng từ thường. Tên người Việt hay là từ thường: Mai, Lan, Hoa, An, Hải. “Chị Mai” là người, “sáng mai” thì không. “Ông An” là người, “bình an” thì không.
  • Tên mới xuất hiện mỗi ngày. Công ty mới, người mới nổi tiếng, sản phẩm mới. Danh sách luôn chậm hơn thực tế.

Dấu hiệu thật sự nằm ở ngữ cảnh: “Ông” hay “Chị” đứng trước, “làm việc cho” đứng trước, “đến” đứng trước. Một mô hình cần nhìn cả câu để quyết định cho từng từ.

Nếu mỗi từ được gắn một nhãn cho biết nó thuộc thực thể nào, ta đọc ngược ra được thực thể. Nhưng một thực thể có thể dài nhiều từ, và hai thực thể có thể nằm sát nhau, như trong tít báo “Trận Việt Nam Thái Lan”. Nhãn phải đủ để biết chỗ một thực thể bắt đầu.

Bài toán của bài này là: gán cho mỗi từ trong câu một nhãn, sao cho từ dãy nhãn đọc lại được mọi thực thể với đúng ranh giới và đúng loại; rồi đo xem mô hình tìm đúng bao nhiêu thực thể.

Mỗi bộ dữ liệu chọn một tập loại thực thể. Bộ phổ biến nhất cho tiếng Anh là CoNLL-2003 (Tjong Kim Sang và De Meulder, 2003), lấy từ tin tức Reuters, với bốn loại:

LoạiNghĩaVí dụ
PERngườiNguyễn Văn An, Angela Merkel
LOCnơi chốnHà Nội, sông Mê Kông
ORGtổ chứcVietcombank, Liên Hợp Quốc
MISCtên riêng khácWorld Cup, tính từ quốc tịch như German

Phần tiếng Anh của CoNLL-2003 có tập huấn luyện khoảng 203 nghìn token và 23.5 nghìn thực thể. Với tiếng Việt, có hai cuộc thi chung (Nguyễn Thị Minh Huyền và cộng sự, 2018): VLSP 2016 (dữ liệu dạng cột kiểu CoNLL, đã tách từ) và VLSP 2018 (văn bản thô gắn thẻ XML, có thực thể lồng nhau). Cả hai dùng các loại chính PER, LOC, ORG, cùng một loại MISC nhỏ.

Còn “tháng 3/2024”? Bốn loại trên không có thời gian. Bộ khác, như OntoNotes, thêm DATE, TIME, MONEY và nhiều loại nữa. Chọn tập loại nào là quyết định của bài toán cụ thể, không có đáp án chung. Bài này dùng PER, LOC, ORG cho mọi ví dụ.

Gọi câu là dãy từ x=(x1,…,xL)\mathbf{x} = (x_1, \dots, x_L), với LL là độ dài câu. NER gán cho mỗi từ xix_i một nhãn yiy_i, được dãy nhãn y=(y1,…,yL)\mathbf{y} = (y_1, \dots, y_L) cùng độ dài. Đây là gán nhãn chuỗi: đầu ra dài đúng bằng đầu vào và thẳng hàng với nó. Gán nhãn từ loại (part-of-speech tagging: danh từ, động từ…) cũng là bài toán dạng này. Khác với dịch máy ở bài Seq2Seq, ở đây không có chuyện độ dài hay trật tự đổi đi.

Cách đơn giản nhất là gắn mỗi từ thẳng loại của nó: PER, LOC, hoặc O (outside, ngoài mọi thực thể). Thử với tít báo:

TừTrậnViệtNamTháiLan
Chỉ ghi loạiOLOCLOCLOCLOC
BIOOB-LOCI-LOCB-LOCI-LOC

Ở đây tên nước được coi là nơi chốn. Có bộ hướng dẫn gán nhãn coi đội bóng là tổ chức; chọn loại nào thì hai tên vẫn cùng loại và đứng sát nhau. Hàng “chỉ ghi loại” không phân biệt được “Việt Nam” + “Thái Lan” với một địa danh dài bốn từ “Việt Nam Thái Lan”. Thông tin bị mất là chỗ bắt đầu.

Mã hóa BIO sửa đúng chỗ đó. Mỗi nhãn là một tiền tố cộng một loại:

  • B-X (begin): từ đầu tiên của một thực thể loại X.
  • I-X (inside): từ thứ hai trở đi của thực thể loại X đang mở.
  • O: ngoài mọi thực thể.

Với KK loại thực thể, tập nhãn có 2K+12K + 1 phần tử. CoNLL-2003 có K=4K = 4 nên có 9 nhãn. Ý tưởng này đến từ bài toán tách cụm danh từ của Ramshaw và Marcus (1995). Có hai biến thể dễ lẫn. Trong IOB1, dạng của tệp CoNLL-2003 gốc, mọi từ trong thực thể đều mang I-, chỉ khi hai thực thể cùng loại đứng sát nhau thì từ đầu của thực thể thứ hai mới mang B-. Trong IOB2, dạng mà bài này và hầu hết thư viện ngày nay dùng, mọi thực thể đều mở bằng B-. Người ta thường gọi IOB2 là “BIO”.

Một biến thể giàu thông tin hơn là BIOES (còn gọi IOBES): thêm E-X cho từ cuối và S-X cho thực thể chỉ có một từ. “Nguyễn Văn An” thành B-PER I-PER E-PER, “Vietcombank” thành S-ORG. Lample và cộng sự (2016) dùng BIOES, nhưng ghi nhận nó không hơn BIO đáng kể trong thí nghiệm của họ.

Giải mã ngược: đọc dãy nhãn từ trái sang phải. B-X mở một thực thể mới. I-X nối dài thực thể đang mở nếu nó cùng loại X. O, hoặc I- khác loại, đóng thực thể đang mở. Còn một trường hợp mô hình hay sinh ra mà dữ liệu đúng (trong IOB2) không bao giờ có: I-X không đứng ngay sau B-X hay I-X, tức là ở đầu câu, sau O, hoặc sau một nhãn khác loại. Ta gọi nó là I- mồ côi. Bài này theo quy ước của công cụ chấm điểm conlleval (dùng ở CoNLL) và chế độ mặc định của thư viện seqeval: I- mồ côi mở một thực thể mới. Chế độ strict của seqeval thì coi nó là sai và bỏ đi. Hai quy ước cho điểm khác nhau, nên khi báo điểm hãy ghi rõ mình dùng quy ước nào.

Trước học sâu, một hệ NER điển hình mô tả mỗi từ bằng các đặc trưng (feature) do người thiết kế: từ có viết hoa không, có số không, có nằm trong gazetteer không, từ đứng trước là gì (“Ông”, “tại”, “công ty”), từ đứng sau là gì. Sau đó một mô hình học xem đặc trưng nào báo hiệu nhãn nào.

Nếu phân loại từng từ một cách độc lập, mô hình có thể sinh ra dãy vô lý như O I-PER hay B-PER I-LOC. Nó không có cách nào biết nhãn của từ bên cạnh. Các mô hình chuỗi trước đó như HMM và MEMM đã chấm điểm cả dãy nhãn, nhưng HMM cần giả định các từ độc lập với nhau khi biết nhãn, còn MEMM mắc lỗi thiên lệch nhãn (label bias). Trường ngẫu nhiên có điều kiện (conditional random field, CRF) của Lafferty, McCallum và Pereira (2001) chấm điểm cả dãy nhãn một lần mà tránh được cả hai vấn đề đó. Dạng thường dùng cho NER cộng hai loại điểm:

s(x,y)=∑i=1Lψi(yi)+∑i=2LAyi−1,yis(\mathbf{x}, \mathbf{y}) = \sum_{i=1}^{L} \psi_i(y_i) + \sum_{i=2}^{L} A_{y_{i-1}, y_i}
  • ψi(yi)\psi_i(y_i) là điểm tại chỗ: từ thứ ii, nhìn đặc trưng của nó và của các từ xung quanh, hợp với nhãn yiy_i tới đâu.
  • Ayi−1,yiA_{y_{i-1}, y_i} là điểm chuyển (transition score), lấy từ một ma trận A\mathbf{A} học được, mỗi ô ứng với một cặp (nhãn trước, nhãn sau). Khi huấn luyện trên dữ liệu IOB2, ô (O, I-PER) học được giá trị rất âm, vì trong dữ liệu đó I-PER không bao giờ đứng ngay sau O.

Điểm được đổi thành xác suất bằng cách lấy mũ rồi chia cho tổng trên mọi dãy nhãn có thể có, ký hiệu y′\mathbf{y}':

p(y∣x)=exp⁡s(x,y)∑y′exp⁡s(x,y′)p(\mathbf{y} \mid \mathbf{x}) = \frac{\exp s(\mathbf{x}, \mathbf{y})}{\sum_{\mathbf{y}'} \exp s(\mathbf{x}, \mathbf{y}')}

Đây là softmax, chỉ khác ở chỗ các “lớp” bây giờ là cả dãy nhãn. Số dãy nhãn tăng theo hàm mũ của LL (9 nhãn, câu 20 từ là 9209^{20} dãy), nhưng vì điểm chỉ phụ thuộc từng cặp nhãn kề nhau, cả tổng ở mẫu số lẫn dãy có điểm cao nhất đều tính được bằng quy hoạch động. Tổng ở mẫu số tính bằng thuật toán forward, dãy tốt nhất tìm bằng Viterbi. Muốn cấm hẳn một chuyển, ta đặt ô tương ứng của A\mathbf{A} bằng −∞-\infty: mọi dãy chứa chuyển đó có exp⁡s=0\exp s = 0.

Năm 2016, Lample và cộng sự thay đặc trưng thủ công bằng một mạng BiLSTM (mạng hồi tiếp đọc câu cả hai chiều) để tính ψi\psi_i, giữ lớp CRF ở trên. Mô hình BiLSTM-CRF này đạt F1 90.94 trên CoNLL-2003 tiếng Anh mà không cần gazetteer.

Với BERT, cách làm đơn giản hơn nữa. Đưa câu vào BERT, bạn nhận được một vectơ hi∈RD\mathbf{h}_i \in \mathbb{R}^{D} cho mỗi token, với DD là số chiều ẩn, đã trộn thông tin của cả câu theo hai chiều. Lưu ý: ở mục này, chỉ số ii chạy trên các token subword của BERT, không phải trên từ như ở phần CRF; mục sau nói cách nối hai cách đếm này. Đặt lên trên một lớp tuyến tính dùng chung cho mọi vị trí:

zi=Whi+b,p(yi=k∣x)=softmax(zi)k\mathbf{z}_i = \mathbf{W} \mathbf{h}_i + \mathbf{b}, \qquad p(y_i = k \mid \mathbf{x}) = \mathrm{softmax}(\mathbf{z}_i)_k

Ở đây W∈RK′×D\mathbf{W} \in \mathbb{R}^{K' \times D}, b∈RK′\mathbf{b} \in \mathbb{R}^{K'} là tham số mới, với K′=2K+1K' = 2K + 1 là số nhãn BIO. Mỗi zi\mathbf{z}_i là một vectơ logit, mỗi nhãn một số. Đây chỉ là một bộ phân loại trên mỗi vectơ đầu ra, gọi là gán nhãn token (token classification). Khi fine-tune, cả BERT lẫn lớp mới cùng được cập nhật. Hàm mất mát là cross-entropy trung bình trên các vị trí có nhãn, gọi tập các vị trí đó là M\mathcal{M}:

L=−1∣M∣∑i∈Mln⁡p(yi∣x)\mathcal{L} = -\frac{1}{|\mathcal{M}|} \sum_{i \in \mathcal{M}} \ln p(y_i \mid \mathbf{x})

Devlin và cộng sự (2019) làm đúng như vậy, không dùng CRF, và đạt F1 92.4 (BERT-base) và 92.8 (BERT-large) trên tập kiểm tra CoNLL-2003. Lý do CRF ít cần hơn: mỗi hi\mathbf{h}_i đã “thấy” cả câu, nên gián tiếp biết từ bên cạnh nhiều khả năng mang nhãn gì, và lỗi kiểu O I-PER hiếm đi. Hiếm, chứ không hết: ô code ở dưới sẽ cho thấy một lỗi như vậy.

Dữ liệu NER gán nhãn theo từ, còn BERT đọc theo subword. Như bài Tách từ đã nói, WordPiece cắt từ lạ thành nhiều mảnh, mảnh sau có tiền tố ##. Đây là cách bộ tách từ của model trong ô code cắt câu “Nguyen Thi Lan works for Vietcombank” (lấy từ lần chạy thật):

Token[CLS]Ng##uy##enT##hiLa##nworksforV##iet##comb##an##k[SEP]
Từ số–00011223455555–
Nhãn-100B-PER-100-100I-PER-100I-PER-100OOB-ORG-100-100-100-100-100

Sáu từ thành 16 token. Cách của BERT gốc, và của hướng dẫn token classification trên Hugging Face: chỉ mảnh đầu của mỗi từ mang nhãn của từ. Các mảnh sau và token đặc biệt [CLS], [SEP] nhận giá trị -100. Vì sao là -100? Hàm cross-entropy của PyTorch có tham số ignore_index, mặc định bằng -100: vị trí mang nhãn này không vào tập M\mathcal{M}, không đóng góp vào loss.

Vì sao không chép nhãn của từ cho mọi mảnh?

  • Chép nguyên B-ORG cho cả 5 mảnh của “Vietcombank” thì lúc giải mã, mỗi B- mở một thực thể mới: một tên thành năm thực thể.
  • Chép rồi đổi mảnh sau thành I- thì hợp lệ, nhưng từ có 5 mảnh chiếm 5 số hạng trong loss, từ “for” chỉ 1. Tên riêng lạ (đúng thứ ta cần tìm) bị cắt vụn nhiều nhất, nên được tính trọng số lệch đi so với cách chấm điểm theo từ.
  • Điểm số được chấm theo từ. Ta cần đúng một dự đoán cho mỗi từ, và mảnh đầu là chỗ tự nhiên để lấy nó.

Hệ quả cần nhớ: nếu model được huấn luyện theo cách -100, nhãn nó đoán cho các mảnh ## chưa bao giờ được dạy. Lúc dùng, hãy đọc nhãn ở mảnh đầu của mỗi từ.

Một thực thể dự đoán chỉ được tính là đúng khi khớp chính xác (exact match) với một thực thể thật: cùng điểm đầu, cùng điểm cuối, cùng loại. Đây là cách chấm của CoNLL-2003, và của seqeval. Gọi NpredN_{\text{pred}} là số thực thể mô hình đưa ra, NgoldN_{\text{gold}} là số thực thể thật, NokN_{\text{ok}} là số khớp chính xác:

P=NokNpred,R=NokNgold,F1=2PRP+RP = \frac{N_{\text{ok}}}{N_{\text{pred}}}, \qquad R = \frac{N_{\text{ok}}}{N_{\text{gold}}}, \qquad F_1 = \frac{2PR}{P + R}

Precision PP trả lời: trong những gì mô hình đưa ra, bao nhiêu phần đúng. Recall RR trả lời: trong những gì có thật, mô hình tìm được bao nhiêu phần. F1F_1 là trung bình điều hòa của hai số. Thay PP và RR vào rồi rút gọn NokN_{\text{ok}} ở tử và mẫu, ta được một dạng dễ nhớ:

F1=2⋅NokNpred⋅NokNgoldNokNpred+NokNgold=2NokNpred+NgoldF_1 = \frac{2 \cdot \frac{N_{\text{ok}}}{N_{\text{pred}}} \cdot \frac{N_{\text{ok}}}{N_{\text{gold}}}}{\frac{N_{\text{ok}}}{N_{\text{pred}}} + \frac{N_{\text{ok}}}{N_{\text{gold}}}} = \frac{2 N_{\text{ok}}}{N_{\text{pred}} + N_{\text{gold}}}

(Nhân cả tử và mẫu với NpredNgold/NokN_{\text{pred}} N_{\text{gold}} / N_{\text{ok}}: tử còn 2Nok2 N_{\text{ok}}, mẫu còn Ngold+NpredN_{\text{gold}} + N_{\text{pred}}.)

Luật “khớp chính xác” rất khắt khe. Mô hình đoán “Văn An” thay vì “Nguyễn Văn An” thì bị tính hai lần lỗi: một dự đoán sai (giảm PP) và một thực thể bỏ sót (giảm RR). Ở mức token, cùng lỗi đó chỉ sai một hai trên mười mấy token. Hơn nữa, phần lớn token trong văn bản là O, nên một mô hình đoán O khắp nơi vẫn có độ chính xác mức token cao mà không tìm ra thực thể nào. Ô code đầu tiên đo cả hai con số trên cùng dữ liệu.

  • Âm tiết hay từ. Chữ Việt viết cách nhau theo âm tiết: “Hà Nội” là hai âm tiết nhưng một từ. Nhiều bộ dữ liệu và mô hình (như PhoBERT của Nguyễn Đạt Quốc và Nguyễn Tuấn Anh, 2020) làm việc trên văn bản đã tách từ, viết “Hà_Nội”. Lỗi tách từ thì kéo theo lỗi biên thực thể. Ví dụ trong bài gán nhãn theo âm tiết cho đơn giản.
  • Viết hoa không đáng tin. Tiếng Việt viết hoa cả chữ đầu câu, danh xưng (“Ông”, “Chủ tịch”) và nhiều từ không phải tên. Trên mạng xã hội thì ngược lại: “di ha noi voi lan”, không hoa, không dấu. Mô hình học trên báo chí chuẩn sẽ hụt mạnh trên kiểu văn bản này.
  • Tên trùng từ thường như đã thấy ở phần Trực giác, và tên viết không dấu thì càng mơ hồ.
  • Thực thể lồng nhau. “Đại học Quốc gia Hà Nội” là tổ chức chứa một nơi chốn. BIO chỉ biểu diễn được một tầng; CoNLL-2003 thường chỉ gán tầng ngoài cùng.
  • Phụ thuộc miền. Model học trên tin tức Reuters không biết tên thuốc, tên linh kiện. Đổi miền thì cần dữ liệu gán nhãn mới, xem hộp thứ ba ở mục Lỗi hay gặp.
  • Nhận dạng chưa phải hiểu. NER tìm ra “Vietcombank” nhưng không biết đó là ngân hàng nào trong cơ sở tri thức. Việc nối tên với một mục cụ thể là bài toán khác, gọi là liên kết thực thể (entity linking).

Bảng dưới là câu đầu tiên trong ô code, với nhãn đúng và nhãn mà một mô hình giả định dự đoán. Hãy nhìn vào cột “Nguyễn” và “Văn”: mô hình chỉ lệch một vị trí ở đầu thực thể.

TừNhãn đúngNhãn dự đoánKhớp?
ÔngOO✓
NguyễnB-PERO✗
VănI-PERB-PER✗
AnI-PERI-PER✓
đếnOO✓
HàB-LOCB-LOC✓
NộiI-LOCI-LOC✓
làmOO✓
việcOO✓
choOO✓
VietcombankB-ORGB-ORG✓
từOO✓
thángOO✓
3/2024OO✓

Mức token: 12 trên 14 token đúng, khoảng 0.86. Mức thực thể: giải mã nhãn đúng ra ba thực thể PER “Nguyễn Văn An”, LOC “Hà Nội”, ORG “Vietcombank”. Giải mã nhãn dự đoán ra PER “Văn An”, LOC “Hà Nội”, ORG “Vietcombank”. “Văn An” không khớp chính xác với “Nguyễn Văn An”, nên chỉ 2 trên 3 thực thể đúng, và người tên Nguyễn Văn An coi như không được tìm thấy. Đổi nhãn thì thành một tên người khác hẳn.

Câu thứ tư trong ô code cho thấy vì sao cần B-. Mô hình giả định ở đây quên mở thực thể mới ở “Thái”:

TừTrậnViệtNamTháiLan…MỹĐình
Nhãn đúngOB-LOCI-LOCB-LOCI-LOC…B-LOCI-LOC
Nhãn dự đoánOB-LOCI-LOCI-LOCI-LOC…OO

Chỉ một nhãn khác ở “Thái”, nhưng giải mã ra một thực thể dài “Việt Nam Thái Lan” thay vì hai. Cả hai thực thể thật bị tính là bỏ sót, cộng thêm một dự đoán sai.

Code trong bài chạy thử lần cuối tháng 11/2026: ô chấm điểm với Python 3.14 trên Pyodide (chỉ Python thuần), ô BERT với Python 3.14, transformers 5.22 và PyTorch 2.14 trên CPU. Ô chấm điểm chạy ngay trên trang và độc lập với ô kia. Ô BERT cần PyTorch và tải model nên chạy trên máy bạn.

Ô đầu tiên cài hàm giải mã BIO thành tập thực thể, theo quy ước ở phần Lý thuyết, rồi chấm bốn câu tiếng Việt. Mỗi thực thể là một bộ (loại, đầu, cuối) với điểm cuối không tính, như lát cắt của Python. Nhờ vậy “khớp chính xác” chỉ là phép giao hai tập: G & P.

Giải mã BIO, F1 mức thực thể và độ chính xác mức token

# Giải mã nhãn BIO thành thực thể, rồi chấm điểm mức thực thể và mức token
def spans(tags):
"""Trả về tập (loại, đầu, cuối); cuối không tính, như lát cắt."""
out, start, typ = set(), None, None
for i, tag in enumerate(tags + ["O"]): # "O" cuối để đóng thực thể
pre, _, ty = tag.partition("-")
# Đóng thực thể đang mở khi gặp O, B- hoặc I- khác loại
if start is not None and (pre != "I" or ty != typ):
out.add((typ, start, i))
start = None
# Mở thực thể mới ở B-, hoặc ở I- mồ côi (quy ước của conlleval)
if pre == "B" or (pre == "I" and start is None):
start, typ = i, ty
return out
data = [ # (câu, nhãn đúng, nhãn dự đoán)
("Ông Nguyễn Văn An đến Hà Nội làm việc cho Vietcombank từ tháng 3/2024",
"O B-PER I-PER I-PER O B-LOC I-LOC O O O B-ORG O O O",
"O O B-PER I-PER O B-LOC I-LOC O O O B-ORG O O O"),
("Chị Mai chuyển từ Bia Hà Nội sang Vinamilk",
"O B-PER O O B-ORG I-ORG I-ORG O B-ORG",
"O B-PER O O O B-LOC I-LOC O B-ORG"),
("Ông An mở tài khoản ở Ngân hàng Ngoại thương",
"O B-PER O O O O B-ORG I-ORG I-ORG I-ORG",
"O B-PER O O O O O I-ORG I-ORG I-ORG"),
("Trận Việt Nam Thái Lan diễn ra ở Mỹ Đình",
"O B-LOC I-LOC B-LOC I-LOC O O O B-LOC I-LOC",
"O B-LOC I-LOC I-LOC I-LOC O O O O O"),
]
n_gold = n_pred = n_ok = tok_ok = tok_all = all_o = 0
for text, g, p in data:
words, g, p = text.split(), g.split(), p.split()
G, P = spans(g), spans(p)
n_gold, n_pred, n_ok = n_gold + len(G), n_pred + len(P), n_ok + len(G & P)
tok_ok += sum(a == b for a, b in zip(g, p))
all_o += g.count("O")
tok_all += len(g)
for ty, s, e in sorted(G - P, key=lambda x: x[1]):
print("missed:", ty, " ".join(words[s:e]))
for ty, s, e in sorted(P - G, key=lambda x: x[1]):
print("wrong: ", ty, " ".join(words[s:e]))
prec, rec = n_ok / n_pred, n_ok / n_gold
print(f"gold={n_gold} pred={n_pred} correct={n_ok}")
print(f"entity P={prec:.3f} R={rec:.3f} F1={2*prec*rec/(prec+rec):.3f}")
print(f"token accuracy={tok_ok / tok_all:.3f} ({tok_ok}/{tok_all})")
print(f"all-O token accuracy={all_o / tok_all:.3f}")

Lần chạy đầu cần tải Python, khoảng 10 MB.

Đọc kết quả:

  • Câu 1 (lỗi biên): missed: PER Nguyễn Văn An và wrong: PER Văn An, đúng như bảng ở mục Minh họa.
  • Câu 2 (lỗi biên và loại): mô hình thấy “Hà Nội” là nơi chốn, mất cả tổ chức “Bia Hà Nội”. Đây chính là chỗ nhập nhằng mà gazetteer cũng mắc.
  • Câu 3 (I- mồ côi): nhãn dự đoán bắt đầu bằng I-ORG sau O. Theo quy ước conlleval, nó vẫn mở thực thể, nhưng ra “hàng Ngoại thương”, thiếu chữ “Ngân”, nên vẫn sai.
  • Câu 4 (thiếu B-): ba thực thể thật bị bỏ sót (hai nước bị gộp, “Mỹ Đình” không được tìm), cộng một dự đoán sai “Việt Nam Thái Lan”.

Tổng lại có 11 thực thể thật, mô hình đưa ra 9, khớp chính xác 5. Vậy P=5/9≈0.556P = 5/9 \approx 0.556, R=5/11≈0.455R = 5/11 \approx 0.455, và F1=2⋅5/(9+11)=0.500F_1 = 2 \cdot 5 / (9 + 11) = 0.500, khớp với dòng entity. Trong khi đó độ chính xác mức token là 34/43, khoảng 0.791: nghe như mô hình đúng gần 80%, nhưng thật ra nó chỉ tìm đúng chưa tới một nửa số thực thể. Dòng cuối là một mô hình đoán O cho mọi token: độ chính xác 0.488 mà không tìm được thực thể nào. Bốn câu ở đây dày đặc tên riêng; trong văn bản thật, tỉ lệ O cao hơn nhiều, nên khoảng cách giữa hai cách đo còn lớn hơn.

Thử sửa nhãn dự đoán của câu 1 thành giống nhãn đúng rồi chạy lại: missed của câu 1 biến mất, NokN_{\text{ok}} lên 6.

Ô thứ hai chạy một model BERT đã fine-tune cho NER: dslim/bert-base-NER. Theo model card, đó là bert-base-cased (Devlin và cộng sự, 2019) fine-tune trên phần tiếng Anh của CoNLL-2003, nên nhận bốn loại PER, LOC, ORG, MISC và đọc câu tiếng Anh. Ô in nhãn trên từng token trước, rồi gộp token thành thực thể theo hai cách.

Chạy trên máy bạn

Pipeline NER với BERT, trên từng token và sau khi gộp

Cần cài
pip install transformers torch
Phần cứng
CPU là đủ. Lần đầu tải model khoảng 433 MB; mỗi lần chạy sau mất chừng một phút trên máy phát triển, phần lớn là nạp thư viện và model
# Nhận dạng thực thể bằng BERT đã fine-tune trên CoNLL-2003
from transformers import pipeline
text = ("Sundar Pichai met Nguyen Thi Lan at the Vietcombank office "
"in Hanoi before flying to Berlin.")
ner = pipeline("ner", model="dslim/bert-base-NER")
# Không gộp: mỗi token (kể cả mảnh ## của WordPiece) một nhãn B-/I-
for tok in ner(text, aggregation_strategy="none")[:11]:
print(f"{tok['entity']:6} {tok['score']:.3f} {tok['word']}")
# Gộp token thành thực thể theo hai cách:
# "simple" gộp theo nhãn của từng token, "first" lấy nhãn subword đầu
for strategy in ["simple", "first"]:
print("---", strategy)
for ent in ner(text, aggregation_strategy=strategy):
word = text[ent["start"]:ent["end"]]
print(f"{ent['entity_group']:4} {ent['score']:.3f} "
f"[{ent['start']:2}, {ent['end']:2}) {word}")

Kết quả mẫu

Lấy từ một lần chạy thật.

B-PER 1.000 Sun
I-PER 0.581 ##dar
I-PER 1.000 Pi
I-PER 0.997 ##cha
I-PER 0.595 ##i
B-PER 0.999 Ng
I-PER 0.973 ##uy
B-PER 0.991 ##en
I-PER 0.999 T
I-PER 0.957 ##hi
I-PER 0.990 La
--- simple
PER 0.834 [ 0, 13) Sundar Pichai
PER 0.986 [18, 22) Nguy
PER 0.977 [22, 32) en Thi Lan
ORG 0.978 [40, 51) Vietcombank
LOC 0.999 [62, 67) Hanoi
LOC 1.000 [85, 91) Berlin
--- first
PER 1.000 [ 0, 13) Sundar Pichai
PER 0.996 [18, 32) Nguyen Thi Lan
ORG 0.993 [40, 51) Vietcombank
LOC 0.999 [62, 67) Hanoi
LOC 1.000 [85, 91) Berlin

Ngoài các dòng trên, thư viện còn in ra vài dòng báo cáo nạp trọng số, trong đó hai tham số bert.pooler được ghi UNEXPECTED. Lớp pooler chỉ dùng cho phân loại cả câu, bộ gán nhãn token không cần nó, nên bỏ qua được.

Đọc kết quả:

  • Phần không gộp (aggregation_strategy="none") trả về các token không mang nhãn O, và ô chỉ in 11 token đầu tiên trong số đó. “Nguyen” bị cắt thành Ng ##uy ##en, và mảnh ##en nhận B-PER, một nhãn vô lý giữa một từ. Nhiều khả năng model được huấn luyện theo cách mảnh đầu như bài báo BERT, nên nhãn ở ##en không đáng tin. Model card không nói rõ cách huấn luyện, chỉ cảnh báo model đôi khi gán nhãn thực thể cho các mảnh subword.
  • Gộp kiểu simple nối các token liền nhau theo nhãn của từng token. B-PER ở ##en mở một thực thể mới, nên một người thành hai mẩu vô nghĩa “Nguy” và “en Thi Lan”. Đây là lỗi biên mà F1 mức thực thể sẽ phạt hai lần.
  • Gộp kiểu first lấy nhãn ở mảnh đầu của mỗi từ, khớp với cách huấn luyện phổ biến. “Nguyen Thi Lan” ra trọn một PER. Mọi thực thể khác có cùng biên và loại như kiểu simple, chỉ điểm là khác.
  • Hai số trong ngoặc là vị trí ký tự đầu và cuối (cuối không tính) trong chuỗi gốc, nên text[18:32] là “Nguyen Thi Lan”. Điểm là xác suất softmax của nhãn, lấy trung bình trên các token (hoặc các từ) của thực thể.

Model không có ngẫu nhiên khi dự đoán, nên mỗi lần chạy trên cùng máy cho cùng kết quả; bản thư viện khác có thể lệch ở chữ số cuối của điểm. Thử thay text bằng một câu tiếng Việt có dấu: model chỉ học tiếng Anh nên sẽ hụt nhiều, lý do có ở hộp cuối của mục dưới.

Câu 1/5 Một câu có 6 từ được gán dãy nhãn O I-ORG I-ORG O B-LOC I-ORG. Giải mã theo quy ước của bài (giống conlleval), bạn được những thực thể nào?
Câu 2/5 Tập kiểm tra có 10 thực thể. Mô hình đưa ra 8 thực thể, trong đó 6 khớp đúng cả biên lẫn loại, 1 đúng loại nhưng thiếu một từ ở đầu, 1 sai loại. Precision, recall và F1 mức thực thể là bao nhiêu?
Câu 3/5 Bạn fine-tune BERT cho NER và gán nhãn cho mọi subword bằng cách chép nhãn của từ: cả 5 mảnh V ##iet ##comb ##an ##k của "Vietcombank" đều nhận B-ORG. Chuyện gì xảy ra?
Câu 4/5 Trên một tập kiểm tra, 85% token có nhãn đúng là O. Một mô hình lười luôn đoán O cho mọi token. Độ chính xác mức token và F1 mức thực thể của nó là bao nhiêu?
Câu 5/5 Một bộ phân loại độc lập trên từng token dự đoán B-PER I-LOC I-LOC cho "Nguyễn Văn An". Đặt thêm một lớp CRF lên trên giúp được gì?

Ý chính:

  • NER tìm các cụm gọi tên người, nơi chốn, tổ chức… với đúng ranh giới và loại. Nó là bài toán gán nhãn chuỗi: mỗi từ một nhãn. Gazetteer không đủ vì cùng một chuỗi đổi loại theo ngữ cảnh, và tên hay trùng từ thường.
  • Mã hóa BIO dùng B-X, I-X, O (2K+12K + 1 nhãn); B- cần để tách hai thực thể cùng loại đứng sát nhau. Giải mã ngược thì phải chọn một quy ước cho I- mồ côi.
  • CRF chấm điểm cả dãy nhãn bằng điểm tại chỗ cộng điểm chuyển, nên tránh được dãy vô lý; BiLSTM-CRF thay đặc trưng thủ công bằng mạng. BERT chỉ cần một lớp tuyến tính trên vectơ mỗi token; nhãn đặt ở subword đầu, các mảnh sau nhận -100 để bỏ khỏi loss.
  • Đánh giá bằng F1 mức thực thể với luật khớp chính xác, F1=2Nok/(Npred+Ngold)F_1 = 2 N_{\text{ok}} / (N_{\text{pred}} + N_{\text{gold}}). Độ chính xác mức token làm điểm đẹp hơn thật vì đa số token là O.

Đọc thêm:

Nội dung theo giấy phép CC BY-NC-SA 4.0. Mã nguồn theo giấy phép MIT.Giới thiệu