Phân loại văn bản dùng lớp fully-connected (mô hình cơ bản nhất của deep learning)

Tác giả: Tuệ Thư (AIO2024), Đình Thắng (TA)

Keywords: mô hình học sâu, học AI online, phân loại văn bản

Nội dung

  1. Giới thiệu về bài toán phân loại văn bản
    1. Preprocessing
    2. Embedding
    3. Fully-connected layer
  2. Ví dụ minh họa xử lý từng bước
    1. Bước 1: Preprocessing
    2. Bước 2: Embedding
    3. Bước 3: Fully-connected layer
  3. Cài đặt dùng PyTorch

Giới thiệu về bài toán phân loại văn bản

Text Classification (phân loại văn bản) là một bài toán quan trọng trong Xử lý Ngôn ngữ Tự nhiên (Natural Language Processing), nhằm gán nhãn hoặc phân loại văn bản vào các nhóm được xác định trước. Ứng dụng của Text Classification rất đa dạng, có thể kể đến như:

  • Spam detection(phát hiện email rác): Tự động phân loại email thành "spam" hoặc "không spam", giúp người dùng tránh bị làm phiền bởi email rác.
  • Sentiment Analysis (phân tích cảm xúc): Xác định cảm xúc (tích cực, tiêu cực, trung lập) trong các bài đánh giá sản phẩm, bình luận mạng xã hội,...
  • Article topic classification (phân loại chủ đề bài viết): Tự động phân loại bài viết thành các chủ đề như thể thao, công nghệ, chính trị, giải trí,...

image

Hình 1: Minh họa bài toán spam detection (phát hiện email rác).

Trong bài toán Text Classification, ta cần chuyển đổi dữ liệu văn bản thành dạng số trước khi đưa vào mạng nơ-ron để học và phân loại. Quá trình này thường bao gồm nhiều bước, từ tiền xử lý dữ liệu, ánh xạ văn bản sang không gian vector, đến huấn luyện mô hình để tối ưu hóa khả năng phân loại. Hình 2 dưới đây minh họa quy trình đơn giản của bài toán này, bao gồm ba bước chính:

image

Hình 2: Cách tiếp cận đơn giản cho bài toán Text Classification.

Preprocessing

Đây là bước tiền xử lý dữ liệu, giúp chuẩn bị văn bản đầu vào cho quá trình xử lý tiếp theo. Quá trình này bao gồm các công đoạn sau:

  • Standardization: Làm sạch văn bản bằng cách chuyển chữ về dạng viết thường, loại bỏ dấu câu, ký tự đặc biệt và các yếu tố không cần thiết khác.
  • Tokenization: Phân tách câu thành các token (từ hoặc cụm từ riêng lẻ) để dễ dàng xử lý.
  • Building Vocabulary: Xây dựng danh sách các từ duy nhất xuất hiện trong toàn bộ tập dữ liệu và gán chỉ số cho từng từ.
  • Vectorization: Chuyển đổi các từ trong câu thành các chỉ số tương ứng trong danh sách từ vựng.

Embedding

Mã hóa các chỉ số thành vector có ý nghĩa, giúp biểu diễn ngữ nghĩa của văn bản trong không gian đa chiều, nơi mỗi vector mang nhiều thông tin về ngữ cảnh, mối quan hệ và các đặc trưng của từ hoặc câu.

Fully-connected layer

Các vector embedding sẽ được đưa vào fully-connected layer để trích xuất đặc trưng, sau đó qua một hàm kích hoạt để tạo đầu ra. Trong hình 2, hàm kích hoạt Sigmoid được sử dụng để chuẩn hóa đầu ra về khoảng (0,1), biểu thị xác suất của một lớp trong phân loại nhị phân. Softmax cũng có thể được sử dụng để chuẩn hóa đầu ra thành một phân phối xác suất, trong đó tổng các xác suất bằng 1. Vì vậy, Softmax thường được dùng cho phân loại đa lớp, giúp mô hình hóa xác suất giữa nhiều nhãn và chọn nhãn có xác suất cao nhất.

Ví dụ minh họa xử lý từng bước

Trong phần này, chúng ta sẽ thực hiện một ví dụ (hình 3) để hiểu rõ từng bước giải quyết bài toán Text Classification.

Mô tả đề bài: Cho đầu vào gồm hai câu, mỗi câu được gán nhãn (label) tương ứng:

  • Positive (1): Câu mang ý nghĩa tích cực.
  • Negative (0): Câu mang ý nghĩa tiêu cực.

Với vocab_size = 8, sequence_length = 5 và hàm kích hoạt Softmax, hãy phân loại hai câu đã cho vào hai nhóm Positive hoặc Negative dựa trên nội dung của chúng.

image

Hình 3: Mô tả đề bài cho bài toán ví dụ về Text Classification.

Bước 1: Preprocessing

(1) Standardization & Tokenization: Chuyển tất cả chữ cái thành chữ thường, sau đó tách từ (word-level tokenization) dựa trên khoảng trắng (hình 4)

image

Hình 4: Minh họa quá trình Standardization và Tokenization trên văn bản đầu vào.

(2) Building Vocabulary: Xác định danh sách các từ duy nhất xuất hiện và gán một chỉ số duy nhất cho mỗi từ, tạo ra danh sách từ vựng (vocabulary) với \texttt{vocab_size = 8}, bao gồm cả các token đặc biệt như (hình 5).

image

Hình 5: Minh họa quá trình Building Vocabulary.

Khi xây dựng danh sách từ vựng, cần chú ý đến:

  • vocab_size
    • Tổng số từ trong danh sách từ vựng, bao gồm hai token đặc biệt .
    • Nếu vocab_size đủ lớn, danh sách từ vựng sẽ chứa tất cả các từ trong corpus cùng với . Khi vocab_size nhỏ hơn, chỉ một số từ có tần suất cao được giữ lại, các từ ít phổ biến có thể bị loại. Nếu nhiều từ có cùng tần suất nhưng tổng số từ đã đạt giới hạn vocab_size, việc chọn từ nào được giữ lại sẽ phụ thuộc vào cách sắp xếp và xử lý dữ liệu.

image

Hình 6: Danh sách từ vựng với hai giá trị vocab_size khác nhau.

  • (Unknown Token): Dùng để thay thế các từ không có trong danh sách từ vựng. Khi vocab_size bị giới hạn, một số từ sẽ không được đưa vào, hoặc khi gặp từ mới không có trong danh sách từ vựng, ta thay thế nó bằng .
  • (Padding Token): Dùng để đệm các câu ngắn nhằm đảm bảo tất cả câu trong tập dữ liệu có cùng độ dài, giúp quá trình xử lý dữ liệu đồng nhất.

(3) Vectorization: Mỗi token trong từng sample được ánh xạ thành một số nguyên, chính là vị trí (index) tương ứng trong danh sách từ vựng, kết hợp với sequence_length = 5, cụ thể như sau:

  • sequence_length: Độ dài cố định của chuỗi token sau khi vector hóa. Nếu chuỗi token ngắn hơn sequence_length, các token sẽ được thêm vào cuối để đảm bảo đủ độ dài. Nếu dài hơn, có thể cắt bớt hoặc xử lý theo yêu cầu cụ thể.

image

Hình 7: Minh họa chuỗi token với các giá trị sequence_length khác nhau.

  • Các token không có trong danh sách từ vựng sẽ được thay thế bằng token đặc biệt .

Do đó:

  • Với sample 1 "I feel full of energy", vì token "of" không có trong danh sách từ vựng, ta thay thế nó bằng token (tương ứng với index = 0). Ngoài ra, vì số lượng token đã bằng sequence_length nên không cần chỉnh sửa gì thêm.
  • Với sample 2 "I am exhausted", tất cả các token đều có trong danh sách từ vựng nên không cần thay thế bằng . Tuy nhiên, do số lượng token ít hơn sequence_length, ta thêm vào cuối để đảm bảo độ dài đồng nhất, với index của là 1.

image

Hình 8: Minh họa quá trình Vectorization.

Hình 9 dưới đây tóm tắt lại toàn bộ quá trình Preprocessing qua từng công đoạn. Sau bước này, ta thu được chuỗi các token được ánh xạ thành các số nguyên, sẵn sàng cho bước tiếp theo.

image

Hình 9: Tóm tắt toàn bộ bước Preprocessing.

Bước 2: Embedding

Sau khi thực hiện Vectorization, chuỗi các số nguyên thu được vẫn chưa đủ đặc trưng và chưa đủ riêng để đại diện cho từng từ. Ví dụ, số 2 đơn thuần không đủ để diễn đạt ý nghĩa của từ "i" hay mối quan hệ giữa các từ trong câu. Do đó, ta cần một cách biểu diễn tốt hơn, giúp thể hiện rõ đặc trưng và ngữ nghĩa của từ.

Embedding là một phương pháp phổ biến để giải quyết vấn đề này, bên cạnh các cách tiếp cận khác như One-Hot Encoding, BoW (Bag-of-Words) hay TF-IDF, và hiện được xem là phương pháp biểu diễn hiệu quả nhất.

Embedding được biểu diễn dưới dạng một ma trận nhúng (embedding matrix) có kích thước (vocab_size, embedding_dim), trong đó mỗi token được ánh xạ thành một vector có embedding_dim chiều, giúp thể hiện đặc trưng và mối quan hệ ngữ nghĩa tốt hơn so với số nguyên đơn thuần.

Trong ví dụ này, ta sử dụng một embedding matrix với embedding_dim = 2 để đơn giản hóa quá trình tính toán với các giá trị trong ma trận được khởi tạo ngẫu nhiên (hình 10).

image

Hình 10: Embedding matrix ánh xạ mỗi token thành một vector 2 chiều.

Hình 11 dưới đây minh họa quá trình ánh xạ token thành vector embedding dùng embedding matrix. Sau khi thực hiện bước Embedding, mỗi token ánh xạ thành một vector embedding có chiều bằng 2. Ví dụ, 'i' được ánh xạ thành vector [1.0281, -1.9094].

image

Hình 11: Quá trình ánh xạ token sang vector trong embedding.

Bước 3: Fully-connected layer

Sau khi hoàn tất bước Preprocessing và Embedding, ta có quy trình xử lý bài toán Text Classification được minh họa với sample 1 như sau (trong bước này, các tính toán sẽ được thực hiện trên sample 1 và được lặp lại tương tự cho sample 2):

image

Hình 12: Quy trình xử lý bài toán Text Classification, minh họa với sample 1.

Dựa vào hình 12, cho đến thời điểm hiện tại, sample 1 đã được chuẩn hóa, tách thành các token và ánh xạ thành số nguyên dựa trên danh sách từ vựng. Tiếp theo, các số nguyên này được tra cứu trong embedding matrix để tạo thành embedding representation, biểu diễn đặc trưng của sample trước khi đưa vào fully-connected layer để xử lý.

Để phù hợp với đầu vào của fully-connected layer, embedding representation cần được flatten, giúp điều chỉnh shape ban đầu từ (batch_size, sequence_length, embedding_dim) thành (batch_size, sequence_length × embedding_dim), trong đó:

  • batch_size: số lượng sample trong một batch.
  • sequence_length: độ dài của chuỗi sau khi tokenized.
  • embedding_dim: số chiều của vector embedding.

Đối với ví dụ chúng ta thực hiện, embedding representation ban đầu có shape (1, 5, 2), tương ứng với batch_size = 1 (xử lý một sample tại một thời điểm), sequence_length = 5 (độ dài chuỗi sau khi tokenized), và embedding_dim = 2 (số chiều của vector embedding). Sau khi được flatten, nó trở thành đầu vào có shape (1, 10), nghĩa là một vector gồm 10 giá trị và sẵn sàng để đưa vào fully-connected layer.

Trong ví dụ này, đầu vào được biến đổi qua fully-connected layer theo công thức: Z=XW+bZ = XW + b, trong đó:

  • XX: đầu vào sau khi flatten, có shape (1, 10).
  • WW: trọng số của fully-connected layer, có shape (10, output_dim). Nếu W được khai báo với shape (output_dim, 10), cần chuyển vị WTW^T trước khi nhân với XX.
  • bb: bias, có shape (output_dim,).
  • ZZ: đầu ra của fully-connected layer trước khi qua hàm kích hoạt, có shape (1, output_dim).

Với bài toán phân loại nhị phân, cụ thể trong ví dụ này là xác định câu có ý nghĩa tích cực hay tiêu cực, ta có output_dim = 2, tương ứng với hai giá trị Z0Z_0Z1Z_1. Do đó, đầu ra cuối cùng có shape (1, 2), biểu diễn xác suất câu thuộc từng nhãn sau khi áp dụng hàm kích hoạt Softmax.

image

Hình 13: Quy trình xử lý Text Classification với thông tin chi tiết về shape của đầu vào và đầu ra.

Dưới đây, chúng ta sẽ thực hiện cụ thể các phép tính trong fully-connected layer với đầu vào XX (tương ứng với sample 1) sau khi đã được flatten, cùng với các giá trị của trọng số WW và bias bb như sau:

image

Hình 14: Chi tiết quá trình tính toán Z trong fully-connected layer.

Hình 14 minh họa quá trình tính toán trong fully-connected layer. Cụ thể, Z0Z_0 được tính bằng cách lấy từng phần tử của XX nhân với từng phần tử tương ứng trong W0W_0 rồi cộng tổng lại (tính tích vô hướng (dot product) giữa XXW0W_0), sau đó cộng thêm b0b_0. Tương tự, Z1Z_1 là tích vô hướng giữa XXW1W_1 cộng thêm b1b_1.

Đầu ra ZZ sau khi tính toán được đưa qua hàm \texttt{Softmax} để chuyển đổi thành phân phối xác suất. Quá trình này giúp biểu diễn xác suất thuộc từng nhãn, từ đó giúp đưa ra quyết định gán nhãn tích cực hay tiêu cực cho sample.

Sau khi áp dụng Softmax, các giá trị Z0Z_0Z1Z_1 được chuyển đổi thành xác suất, đảm bảo tổng của chúng bằng 1. Như minh họa trong hình 15, xác suất dự đoán cho nhãn Negative (0) là 0.6438, trong khi xác suất cho nhãn Positive (1) là 0.3562. Điều này cho thấy mẫu đầu vào có khả năng cao hơn thuộc Negative (0).

image

Hình 15: Áp dụng Softmax để chuyển đổi Z thành phân phối xác suất.

Để đánh giá mức độ chính xác của dự đoán so với nhãn thực tế, ta sử dụng hàm mất mát Cross Entropy. Hàm này giúp đo lường sai lệch giữa phân phối xác suất dự đoán và nhãn thực, từ đó hướng dẫn mô hình cập nhật trọng số nhằm cải thiện độ chính xác trong các lần huấn luyện tiếp theo. Hàm mất mát Cross Entropy được định nghĩa như sau: L=iyilog(y^i),L = - \sum_{i} y_i \log(\hat{y}_i), trong đó:

  • yiy_i: nhãn thực tế, được biểu diễn bằng one-hot encoding, trong đó yi=1y_i = 1 nếu mẫu thuộc lớp thứ ii, ngược lại yi=0y_i = 0.
  • y^i\hat{y}_i: xác suất dự đoán cho lớp i, sau khi đi qua hàm Softmax.

Hình 16 dưới đây minh họa quá trình tính toán hàm mất mát Cross Entropy, với sample 1 "I feel full of energy" được gán nhãn 1 (Positive), tương ứng với yy = [0, 1] và có y^\hat{y} = [0.6438, 0.3562] sau khi qua hàm Softmax.

image

Hình 16: Quá trình tính toán loss bằng Cross Entropy.

Với các giá trị yyy^\hat{y} như trong hình 16, công thức Cross Entropy được áp dụng để đo lường mức độ sai lệch giữa dự đoán và nhãn thực tế. Kết quả thu được giá trị mất mát L=1.03L = 1.03, phản ánh rằng dự đoán chưa khớp hoàn toàn với nhãn mong muốn.

Để giảm mất mát, ta dùng Stochastic Gradient Descent (SGD) để cập nhật tham số bằng cách tính gradient và điều chỉnh theo hướng giảm nhanh nhất. Hệ số học (learning rate) quyết định tốc độ cập nhật trọng số, bias và embedding matrix. Nếu embedding matrix được khởi tạo ngẫu nhiên, cần cập nhật để cải thiện khả năng biểu diễn; ngược lại, nếu embedding matrix đã đủ tốt, ta có thể giữ nguyên. Trong ví dụ này, do embedding matrix được khởi tạo ngẫu nhiên, việc cập nhật là cần thiết.

image

Hình 17: Cập nhật tham số và tối ưu hóa loss.

Sau khi cập nhật các tham số (hình 17), giá trị trọng số, bias và embedding matrix đã thay đổi theo hướng tối ưu hơn. Khi sử dụng các giá trị mới này để tính toán lại ZZ, sau đó áp dụng hàm Softmax, ta thu được xác suất dự đoán mới: xác suất của lớp đầu tiên giảm xuống 0.3271, trong khi xác suất của lớp thứ hai tăng lên 0.6729. Kết quả này cho thấy mô hình đang điều chỉnh để dự đoán chính xác hơn. Đồng thời, giá trị mất mát LL = 0.40 đã giảm so với trước khi cập nhật, phản ánh rằng quá trình tối ưu hóa đang diễn ra hiệu quả.

Cài đặt dùng PyTorch

Trong phần này, chúng ta sẽ triển khai lại các bước tính toán đã thực hiện ở phần trước dưới dạng lập trình, nhằm kiểm chứng kết quả và quan sát quá trình xử lý một cách trực quan hơn.

image

Hình 18: Tóm tắt đề bài của ví dụ Text Classification đã thực hiện.

Import library Đầu tiên, chúng ta sẽ import các thư viện cần thiết để xử lý dữ liệu và xây dựng mô hình.

import torch
import torch.nn as nn
from tokenizers import Tokenizer
from tokenizers.models import WordLevel
from tokenizers.trainers import WordLevelTrainer
from tokenizers.pre_tokenizers import Whitespace

Preprocessing

  • Khởi tạo dữ liệu và nhãn: Trong phần này, chúng ta định nghĩa tập dữ liệu đầu vào (corpus) gồm hai câu với nhãn tương ứng (labels), trong đó 1 biểu thị cảm xúc tích cực (Positive) và 0 biểu thị cảm xúc tiêu cực (Negative). Đồng thời, ta khai báo \texttt{vocab_size} và \texttt{sequence_length} để chuẩn bị dữ liệu cho các bước xử lý tiếp theo.
corpus = [
    "I feel full of energy",
    "I am exhausted"
]

# 0: negative - 1: positive
labels = [1, 0]

# Define the max vocabulary size and sequence length
vocab_size = 8
sequence_length = 5

Tokenization

Đoạn code dưới đây khởi tạo một tokenizer WordLevel, trong đó mỗi từ sẽ được ánh xạ thành một chỉ số riêng biệt sau khi xây dựng danh sách từ vựng. \texttt{tokenizer} được thiết lập để tách từ dựa trên khoảng trắng, nghĩa là mỗi từ được phân biệt nhờ dấu cách giữa chúng. Bên cạnh đó, nếu chuỗi ngắn hơn sequence_length, tokenizer sẽ thêm token vào cuối để đảm bảo tất cả các chuỗi có cùng độ dài. Nếu chuỗi quá dài, nó sẽ bị cắt bớt để không vượt quá sequence_length.

# Define tokenizer
tokenizer = Tokenizer(WordLevel())
tokenizer.pre_tokenizer = Whitespace()
tokenizer.enable_padding(pad_id=1, 
                         pad_token="<pad>", 
                         length=sequence_length)
tokenizer.enable_truncation(max_length=sequence_length)

Building Vocabulary

Phần này xây dựng vocabulary từ tập dữ liệu bằng cách tạo một bộ huấn luyện trainer quy định số lượng từ tối đa trong danh sách từ vựng và bổ sung các token đặc biệt unkpad. Tiếp theo, tokenizer sẽ lặp qua corpus, tách từ và học danh sách từ vựng. Kết quả thu được là một vocabulary dùng để mã hóa văn bản.

# Train the tokenizer 
trainer = WordLevelTrainer(vocab_size=vocab_size, 
                           special_tokens=["<unk>", "<pad>"])
tokenizer.train_from_iterator(corpus, trainer)

Vectorization

Đoạn code này chuyển văn bản thành dạng số bằng cách token hóa và ánh xạ từng từ thành chỉ số. Hàm vectorize nhận một câu đầu vào, sử dụng tokenizer để mã hóa và trả về tensor chứa các chỉ số tương ứng. Sau đó, mỗi câu trong corpus được xử lý bằng vectorize và lưu vào danh sách corpus_ids.

# Tokenize and numericalize the samples
def vectorize(sentence, tokenizer):
    output = tokenizer.encode(sentence)
    return torch.tensor(output.ids, dtype=torch.long)

# Vectorize the samples
corpus_ids = []
for sentence in corpus:
    corpus_ids.append(vectorize(sentence, tokenizer))

# "I feel full of energy"  -->  [2, 6, 7, 0, 4]
#        "I am exhausted"  -->  [2, 3, 5, 1, 1]

Embedding

Tiến hành khởi tạo embedding matrix, sau đó ánh xạ các câu vào không gian nhúng. Kết quả thu được embedding representation có shape (1, 5, 2).

# With vocab_size = 8, define embedding dimension (2)
embedding_dim = 2
embedding = nn.Embedding(vocab_size, embedding_dim)

input_1 = torch.tensor([[2, 6, 7, 0, 4]], dtype=torch.long)
label_1 = torch.tensor([1], dtype=torch.long)
embedded_output_1 = embedding(input_1)

input_2 = torch.tensor([[2, 3, 5, 1, 1]], dtype=torch.long)
label_2 = torch.tensor([0], dtype=torch.long)
embedded_output_2 = embedding(input_2)

# Shape of embedded_output_1 and embedded_output_2: torch.Size([1, 5, 2])

Fully-connected layer

Lớp nn.Flatten chuyển đổi đầu vào từ dạng (batch_size, sequence_length, embedding_dim) thành (batch_size, sequence_length * embedding_dim).

# Flatten                           
flatten = nn.Flatten()
flattened_output_1 = flatten(embedded_output_1)

# Shape of flattened_output_1: torch.Size([1, 10])

Tiếp theo, lớp nn.Linear(10, 2) đóng vai trò thực hiện phép biến đổi tuyến tính với 10 đầu vào và 2 đầu ra. Trong PyTorch, khi khởi tạo lớp nn.Linear, các tham số trọng số WW và bias bb được tự động khởi tạo ngẫu nhiên. Mô hình được xây dựng gồm ba phần: embedding để chuyển đổi từ vựng thành vector nhúng, flatten để làm phẳng đầu ra, và fc để biến đổi đặc trưng.

Lưu ý: Lớp nn.Linear trong PyTorch có cách tổ chức trọng số khác so với phép nhân ma trận thông thường. Khi khởi tạo nn.Linear(in_features, out_features), PyTorch sẽ tự động khởi tạo ma trận trọng số WW với kích thước (out_features, in_features), nghĩa là (2, 10) trong trường hợp này. Vì vậy, nếu tự khởi tạo trọng số, cần chú ý sử dụng đúng kích thước này.

# Fully connected (FC) layer: 10 input features and 2 output features
fc = nn.Linear(10, 2)

# Define a simple model with embedding, flattening, and FC transformation
model = nn.Sequential(embedding, flatten, fc)

output_sample_1 = model(input_1)

Hàm mất mát CrossEntropyLoss được sử dụng để đo độ chênh lệch giữa đầu ra của mô hình và nhãn thực tế. Trong PyTorch, CrossEntropyLoss đã bao gồm bước Softmax. Do đó, đầu vào của hàm mất mát là đầu ra trực tiếp từ mô hình (chưa qua Softmax).

# Calculate loss
criterion = nn.CrossEntropyLoss()
loss = criterion(output_sample_1, label_1)
print(loss)

Thực hiện cập nhật trọng số mô hình bằng thuật toán tối ưu SGD với tốc độ học lr=0.1lr = 0.1. Sau khi tính gradient thông qua loss.backward(), optimizer.step() sẽ cập nhật các tham số của mô hình.

# Update parameters with SGD
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)
loss.backward()
optimizer.step()

Sau đây là phần code đầy đủ cho bài phân loại dùng mô hình chứa một lớp linear:

from tokenizers import Tokenizer
from tokenizers.models import WordLevel
from tokenizers.trainers import WordLevelTrainer
from tokenizers.pre_tokenizers import Whitespace
import torch
import torch.nn as nn

# ================ data ===================
corpus = [
    "I feel full of energy",
    "I am exhausted"
]
labels = [1, 0]  # 0: negative - 1: positive
vocab_size = 8
sequence_length = 5

# Initialize the tokenizer and define a trainer
tokenizer = Tokenizer(WordLevel())
tokenizer.pre_tokenizer = Whitespace()
tokenizer.enable_padding(pad_id=1, 
                         pad_token="<pad>", 
                         length=sequence_length)
tokenizer.enable_truncation(max_length=sequence_length)

# Train the tokenizer on your corpus
trainer = WordLevelTrainer(vocab_size=vocab_size, 
                           special_tokens=["<unk>", "<pad>"])
tokenizer.train_from_iterator(corpus, trainer)

# Tokenize and numericalize your samples
def vectorize(sentence, tokenizer):
    output = tokenizer.encode(sentence)
    return torch.tensor(output.ids, dtype=torch.long)

# Vectorize the samples
corpus_ids = []
for sentence in corpus:
    corpus_ids.append(vectorize(sentence, tokenizer))

# Convert to tensor
inputs = torch.stack(corpus_ids)
labels = torch.tensor(labels, dtype=torch.long)

# ================ model ================
embedding = nn.Embedding(vocab_size, 2)
flatten = nn.Flatten()
fc = nn.Linear(10, 2)
model = nn.Sequential(embedding, flatten, fc)

# ================ train ================
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)

for _ in range(50):
    optimizer.zero_grad()
    outputs = model(inputs)
    loss = criterion(outputs, labels)
    loss.backward()
    optimizer.step()

# ================ verify ================
outputs = model(inputs)
print(torch.softmax(outputs, axis=-1))
# tensor([[0.0194, 0.9806],
#        [0.9773, 0.0227]], grad_fn=<SoftmaxBackward0>)

----------------------------------------- Hết -----------------------------------------