Bỏ qua

Học Máy Cổ Điển (Classical Machine Learning)

Các thuật toán Học Máy cổ điển học các mẫu từ dữ liệu mà không cần lập trình tường minh, sử dụng các nghiệm dạng đóng (closed-form) hoặc tìm kiếm heuristic thay vì hạ gradient. File này bao gồm Naive Bayes, k-NN, cây quyết định, rừng ngẫu nhiên, SVM, phân cụm k-means và PCA.

  • Học máy là ngành nghiên cứu về các thuật toán cải thiện hiệu suất trên một nhiệm vụ nào đó bằng cách học từ dữ liệu, thay vì được lập trình tường minh với các luật. Thay vì viết "if income > 50k and age < 30 then approve loan," bạn đưa cho thuật toán hàng ngàn quyết định cho vay trong quá khứ và để nó tự tìm ra mẫu.

  • Có ba nhánh lớn. Học có giám sát (supervised learning) sử dụng dữ liệu có gắn nhãn, nghĩa là mỗi đầu vào đi kèm với một đầu ra đúng đã biết. Thuật toán học một ánh xạ từ đầu vào sang đầu ra. Học không giám sát (unsupervised learning) làm việc với dữ liệu không gắn nhãn và cố gắng khám phá cấu trúc ẩn, như các cụm hay biểu diễn nén. Học tăng cường (reinforcement learning) học thông qua thử và sai, nhận phần thưởng hoặc hình phạt cho các hành động trong một môi trường (sẽ được trình bày trong file 04).

  • Trong học có giám sát, phân lớp (classification) dự đoán các danh mục rời rạc (spam hay không spam, mèo hay chó) trong khi hồi quy (regression) dự đoán các giá trị liên tục (giá nhà, nhiệt độ ngày mai). Ranh giới không phải lúc nào cũng rõ ràng: hồi quy logistic tuy mang tên "hồi quy" nhưng thực hiện phân lớp.

  • Một phân biệt quan trọng trong các mô hình xác suất là sinh (generative) vs phân biệt (discriminative). Mô hình sinh học phân bố đồng thời \(P(x, y)\), nghĩa là nó hiểu cách dữ liệu được sinh ra. Nó có thể tạo ra các mẫu mới. Mô hình phân biệt học trực tiếp \(P(y \mid x)\), chỉ tập trung vào ranh giới giữa các lớp. Naive Bayes là mô hình sinh; hồi quy logistic (file 02) là mô hình phân biệt. Mô hình sinh linh hoạt hơn nhưng khó huấn luyện tốt; mô hình phân biệt thường cho độ chính xác phân lớp cao hơn khi có đủ dữ liệu.

  • Naive Bayes là một trong những bộ phân lớp đơn giản và hiệu quả nhất. Nó áp dụng trực tiếp định lý Bayes (từ chương 05):

\[P(C_k \mid x) = \frac{P(x \mid C_k) \, P(C_k)}{P(x)}\]
  • Phần "ngây thơ (naive)" nằm ở giả định độc lập mạnh: nó coi mỗi đặc trưng là độc lập với nhau khi biết lớp. Nếu bạn phân loại email là spam, Naive Bayes giả định rằng sự xuất hiện của từ "free" không cho bạn biết gì về sự xuất hiện của từ "winner", một khi bạn biết email đó là spam. Điều này hầu như không bao giờ đúng trong thực tế, nhưng bộ phân lớp vẫn hoạt động tốt đáng ngạc nhiên.

  • \(P(x)\) giống nhau cho tất cả các lớp, việc phân lớp đơn giản hóa thành chọn lớp cực đại hóa tử số:

\[\hat{y} = \arg\max_{k} \; P(C_k) \prod_{i=1}^{n} P(x_i \mid C_k)\]
  • Tiên nghiệm \(P(C_k)\) chỉ đơn giản là tỷ lệ các ví dụ huấn luyện trong mỗi lớp. Các hợp lý \(P(x_i \mid C_k)\) phụ thuộc vào loại đặc trưng bạn có, dẫn đến ba biến thể phổ biến.

  • Multinomial Naive Bayes được thiết kế cho dữ liệu đếm, như tần số từ trong tài liệu. Mỗi đặc trưng \(x_i\) biểu diễn số lần từ \(i\) xuất hiện, và hợp lý tuân theo phân bố đa thức (multinomial). Đây là lựa chọn tiêu chuẩn cho phân loại văn bản, phân tích cảm xúc và lọc spam.

  • Gaussian Naive Bayes giả định mỗi đặc trưng tuân theo phân bố chuẩn trong mỗi lớp. Bạn ước lượng trung bình \(\mu_{ik}\) và phương sai \(\sigma_{ik}^2\) của đặc trưng \(i\) cho lớp \(k\) từ dữ liệu huấn luyện, sau đó tính:

\[P(x_i \mid C_k) = \frac{1}{\sqrt{2\pi\sigma_{ik}^2}} \exp\!\left(-\frac{(x_i - \mu_{ik})^2}{2\sigma_{ik}^2}\right)\]
  • Đây là lựa chọn tự nhiên khi các đặc trưng của bạn là các đo lường liên tục, như chiều cao, cân nặng hay các giá trị cảm biến.

Hai phân bố lớp có điều kiện Gaussian chồng lấn với ranh giới quyết định nơi hậu nghiệm giao nhau

  • Bernoulli Naive Bayes mô hình hóa các đặc trưng nhị phân: mỗi đặc trưng hoặc có mặt (1) hoặc vắng mặt (0). Thay vì đếm số lần một từ xuất hiện, bạn chỉ theo dõi xem nó có xuất hiện hay không. Cách này hoạt động tốt cho văn bản ngắn hoặc vector đặc trưng nhị phân.

  • Một vấn đề thực tế phát sinh khi một giá trị đặc trưng không bao giờ xuất hiện với một lớp nào đó trong dữ liệu huấn luyện. Hợp lý trở thành 0, và vì mọi thứ được nhân với nhau, toàn bộ hậu nghiệm sụp đổ về 0. Làm mịn Laplace (Laplace smoothing) khắc phục điều này bằng cách thêm một số đếm nhỏ (thường là 1) vào mọi tổ hợp đặc trưng-lớp:

\[P(x_i \mid C_k) = \frac{\text{count}(x_i, C_k) + \alpha}{\text{count}(C_k) + \alpha \cdot V}\]
  • Ở đây \(\alpha\) là tham số làm mịn (thường là 1) và \(V\) là số giá trị khả dĩ cho đặc trưng đó. Điều này đảm bảo không có xác suất nào bằng chính xác 0.

  • Cây quyết định (Decision trees) có một cách tiếp cận hoàn toàn khác. Thay vì tính xác suất, chúng phân hoạch không gian đặc trưng thông qua một chuỗi các câu hỏi có/không. Hãy nghĩ về trò chơi Twenty Questions: ở mỗi bước, bạn hỏi câu hỏi thu hẹp khả năng nhất.

  • Một cây bắt đầu tại gốc (root) với tất cả các ví dụ huấn luyện. Tại mỗi nút bên trong, nó chọn một đặc trưng và một ngưỡng để phân tách (ví dụ: "tuổi < 30?"). Các ví dụ đi sang trái hoặc phải dựa trên câu trả lời. Quá trình này tiếp tục đệ quy cho đến các lá (leaves), nơi chứa các dự đoán: lớp đa số cho phân loại, hoặc giá trị trung bình cho hồi quy.

Cây quyết định với các phân tách độ sâu 2 trên các đặc trưng, nhánh có/không, và các nút lá màu thể hiện dự đoán lớp

  • Câu hỏi quan trọng là: bạn nên phân tách trên đặc trưng nào? Bạn muốn các phân tách tạo ra các nút con "thuần khiết" nhất, nơi hầu hết các ví dụ thuộc cùng một lớp. Hai thước đo độ pha tạp (impurity) phổ biến là Gini impurityentropy.

  • Gini impurity đo xác suất một mẫu được chọn ngẫu nhiên bị phân lớp sai nếu được gán nhãn theo phân bố trong nút đó:

\[\text{Gini}(S) = 1 - \sum_{k=1}^{K} p_k^2\]
  • Nếu một nút hoàn toàn thuần khiết (tất cả một lớp), Gini bằng 0. Nếu các lớp cân bằng hoàn hảo (ví dụ 50/50 cho hai lớp), Gini đạt cực đại 0.5.

  • Entropy (từ phần lý thuyết thông tin chương 05) đo độ bất ngờ trung bình:

\[H(S) = -\sum_{k=1}^{K} p_k \log_2 p_k\]
  • Một nút thuần khiết có entropy 0. Một nút nhị phân cân bằng hoàn hảo có entropy 1 bit. Trong thực tế, Gini và entropy cho các cây rất giống nhau; Gini tính nhanh hơn một chút vì không cần logarit.

  • Lượng thông tin thu được (Information gain) là mức giảm độ pha tạp đạt được bởi một phân tách. Với một phân tách chia tập \(S\) thành các tập con \(S_L\)\(S_R\):

\[\text{IG}(S, \text{split}) = H(S) - \frac{|S_L|}{|S|} H(S_L) - \frac{|S_R|}{|S|} H(S_R)\]
  • Thuật toán tham lam chọn phân tách có lượng thông tin thu được cao nhất tại mỗi nút. Đây là một chiến lược tối ưu cục bộ, không phải tối ưu toàn cục, nhưng hoạt động tốt trong thực tế.

  • Cây hồi quy (Regression trees) hoạt động tương tự, nhưng các lá dự đoán một giá trị liên tục (trung bình của các ví dụ đến được lá đó) và tiêu chí phân tách sử dụng giảm phương sai thay vì Gini hay entropy.

  • Nếu không kiểm soát, một cây quyết định sẽ tiếp tục phân tách cho đến khi mọi lá đều thuần khiết, về cơ bản là ghi nhớ dữ liệu huấn luyện. Đây là quá khớp (overfitting) nghiêm trọng. Tỉa (Pruning) chống lại điều này. Tiền tỉa (pre-pruning) đặt các giới hạn trước khi phát triển cây: độ sâu tối đa, số mẫu tối thiểu trên mỗi lá, hoặc lượng thông tin thu được tối thiểu để thực hiện phân tách. Hậu tỉa (post-pruning) phát triển cây đầy đủ trước, sau đó loại bỏ các nhánh không cải thiện hiệu suất trên tập validation.

  • Một cây quyết định đơn lẻ dễ diễn giải nhưng thường không ổn định: những thay đổi nhỏ trong dữ liệu có thể tạo ra một cây rất khác. Phương pháp tổ hợp (Ensemble methods) kết hợp nhiều mô hình để đạt được dự đoán tốt hơn bất kỳ mô hình đơn lẻ nào.

  • Ý tưởng cốt lõi là "trí tuệ tập thể." Nếu bạn hỏi 100 bộ phân lớp trung bình và lấy biểu quyết đa số, tổ hợp có thể xuất sắc, miễn là các bộ phân lớp riêng lẻ mắc các lỗi tương đối độc lập.

  • Bagging (bootstrap aggregating) huấn luyện nhiều mô hình trên các tập con ngẫu nhiên khác nhau của dữ liệu, được lấy mẫu có hoàn lại (bootstrap samples). Mỗi mô hình thấy khoảng 63% dữ liệu gốc. Khi dự đoán, bạn lấy trung bình các đầu ra (hồi quy) hoặc biểu quyết đa số (phân lớp). Vì mỗi mô hình thấy dữ liệu khác nhau, chúng mắc các lỗi khác nhau, và việc lấy trung bình loại bỏ phần lớn phương sai.

  • Rừng ngẫu nhiên (Random Forests) là bagging áp dụng cho cây quyết định với một điểm bổ sung: tại mỗi phân tách, cây chỉ xem xét một tập con ngẫu nhiên của các đặc trưng (thường là \(\sqrt{d}\) đặc trưng trong tổng số \(d\)). Điều này làm giảm tương quan giữa các cây hơn nữa, khiến tổ hợp mạnh mẽ hơn. Rừng ngẫu nhiên là một trong những bộ phân lớp đáng tin cậy nhất trong toàn bộ học máy.

So sánh song song: bagging huấn luyện các mô hình song song và lấy trung bình, boosting huấn luyện tuần tự các mô hình sửa lỗi của mô hình trước

  • Boosting đi theo triết lý ngược lại. Thay vì huấn luyện các mô hình độc lập, nó huấn luyện chúng tuần tự, với mỗi mô hình mới tập trung vào các ví dụ mà mô hình trước đã dự đoán sai.

  • AdaBoost (Adaptive Boosting) duy trì một trọng số cho mỗi ví dụ huấn luyện. Ban đầu tất cả trọng số bằng nhau. Sau khi huấn luyện một bộ học yếu (weak learner, thường là một cây quyết định rất nông, gọi là "stump"), các ví dụ bị phân lớp sai được tăng trọng số, để bộ học tiếp theo chú ý nhiều hơn đến chúng. Dự đoán cuối cùng là một biểu quyết có trọng số của tất cả các bộ học, trong đó các bộ học tốt hơn có tiếng nói lớn hơn:

\[H(x) = \text{sign}\!\left(\sum_{t=1}^{T} \alpha_t \, h_t(x)\right)\]
  • Trọng số \(\alpha_t\) cho bộ học \(t\) phụ thuộc vào tỷ lệ lỗi \(\epsilon_t\) của nó:
\[\alpha_t = \frac{1}{2} \ln\!\left(\frac{1 - \epsilon_t}{\epsilon_t}\right)\]
  • Một bộ học có lỗi thấp nhận trọng số dương lớn; một bộ học hoạt động ngang cơ hội (\(\epsilon = 0.5\)) nhận trọng số 0.

  • Gradient Boosting tổng quát hóa ý tưởng này. Thay vì đặt lại trọng số cho các ví dụ, mỗi mô hình mới được huấn luyện để dự đoán các sai số dư (negative gradient của hàm mất mát) của tổ hợp hiện tại. Với mất mát bình phương (squared error loss), các sai số dư chính là hiệu số giữa dự đoán và mục tiêu. Gradient boosting với cây quyết định (GBDT) đứng sau nhiều giải pháp chiến thắng trong các cuộc thi dữ liệu có cấu trúc (XGBoost, LightGBM, CatBoost là các triển khai phổ biến).

  • Sự khác biệt chính: bagging giảm phương sai (variance) (làm nhiễu triệt tiêu lẫn nhau) trong khi boosting giảm độ chệch (bias) (sửa các lỗi hệ thống). Bagging hoạt động tốt nhất khi các mô hình riêng lẻ quá khớp; boosting hoạt động tốt nhất khi chúng thiếu khớp (underfit).

  • Chuyển sang học không giám sát, Phân cụm K-Means là thuật toán phân cụm đơn giản và được sử dụng rộng rãi nhất. Với \(n\) điểm dữ liệu và số cụm mục tiêu \(K\), nó gán mỗi điểm vào một trong \(K\) nhóm bằng cách tối thiểu hóa tổng khoảng cách từ mỗi điểm đến tâm cụm của nó.

  • Thuật toán luân phiên hai bước. Đầu tiên, gán (assign) mỗi điểm vào tâm (centroid) gần nhất. Thứ hai, cập nhật (update) mỗi tâm thành trung bình của tất cả các điểm được gán cho nó. Lặp lại cho đến khi các gán không thay đổi. Thuật toán được đảm bảo hội tụ vì tổng khoảng cách trong cụm giảm (hoặc giữ nguyên) ở mỗi bước.

Biểu đồ phân tán 2D với ba cụm điểm màu, các điểm đánh dấu tâm và ranh giới cụm nét đứt

  • Về mặt hình thức, K-Means tối thiểu hóa tổng bình phương trong cụm, gọi là quán tính (inertia):
\[J = \sum_{k=1}^{K} \sum_{x \in C_k} \|x - \mu_k\|^2\]
  • trong đó \(\mu_k\) là tâm của cụm \(C_k\).

  • K-Means nhạy cảm với khởi tạo. Các tâm khởi tạo tồi có thể dẫn đến các cực tiểu địa phương kém. Chiến lược khởi tạo K-Means++ chọn tâm đầu tiên ngẫu nhiên, sau đó chọn mỗi tâm tiếp theo với xác suất tỷ lệ với bình phương khoảng cách từ nó đến tâm gần nhất hiện có. Cách này trải rộng các tâm ban đầu và hầu như luôn cho kết quả tốt hơn.

  • Làm thế nào để chọn \(K\)? Hai công cụ phổ biến. Phương pháp khuỷu tay (elbow method) vẽ biểu đồ quán tính theo \(K\) và tìm "khuỷu tay" nơi thêm nhiều cụm hơn không còn giúp ích nhiều. Điểm silhouette (silhouette score) đo mức độ tương đồng của một điểm với cụm của nó so với cụm gần nhất khác, từ -1 (sai cụm) đến +1 (phân cụm tốt). Điểm silhouette trung bình trên tất cả các điểm cho một thước đo tổng thể về chất lượng cụm.

  • K-Means có các hạn chế: nó giả định các cụm hình cầu có kích thước xấp xỉ bằng nhau, và nó tạo ra các gán "cứng" (mỗi điểm thuộc về chính xác một cụm). Mô hình hỗn hợp Gaussian (Gaussian Mixture Models - GMM) nới lỏng cả hai hạn chế.

  • Một GMM mô hình hóa dữ liệu như một hỗn hợp của \(K\) phân bố Gaussian, mỗi phân bố có trung bình \(\mu_k\), hiệp phương sai \(\Sigma_k\) và trọng số pha trộn \(\pi_k\) riêng (với tổng các trọng số bằng 1):

\[P(x) = \sum_{k=1}^{K} \pi_k \, \mathcal{N}(x \mid \mu_k, \Sigma_k)\]
  • Thay vì gán cứng, mỗi điểm nhận một gán mềm (soft assignment): xác suất (gọi là "trách nhiệm" - responsibility) mà nó thuộc về mỗi cụm. Một điểm gần ranh giới giữa hai Gaussian có thể 60% thuộc cụm A và 40% thuộc cụm B.

  • GMM được khớp bằng thuật toán Kỳ vọng-Cực đại hóa (Expectation-Maximisation - EM), luân phiên hai bước, khá giống K-Means. Bước E tính các trách nhiệm: với mỗi điểm, xác suất nó đến từ mỗi Gaussian là bao nhiêu? Bước M cập nhật các tham số: với các trách nhiệm đã biết, các trung bình, hiệp phương sai và trọng số pha trộn tốt nhất là gì? EM được đảm bảo làm tăng hợp lý dữ liệu ở mỗi lần lặp và hội tụ đến một cực đại địa phương.

  • K-Means thực ra là một trường hợp đặc biệt của EM với GMM: nó tương ứng với các Gaussian hình cầu với hiệp phương sai bằng nhau và các trách nhiệm cứng (0/1).

  • Máy vectơ hỗ trợ (Support Vector Machines - SVM) tiếp cận phân lớp từ góc nhìn hình học. Với hai lớp khả tách tuyến tính, có vô số siêu phẳng phân tách chúng. SVM tìm siêu phẳng có lề cực đại (maximum margin), khoảng cách lớn nhất có thể giữa siêu phẳng và các điểm dữ liệu gần nhất từ mỗi lớp.

  • Các điểm gần nhất, nằm ngay trên rìa của lề, được gọi là vector hỗ trợ (support vectors). Chúng là những điểm duy nhất quan trọng để xác định ranh giới; bạn có thể loại bỏ tất cả các điểm huấn luyện khác và vẫn thu được cùng một siêu phẳng.

Hai lớp được phân tách bởi siêu phẳng lề cực đại, với các dải lề và các vector hỗ trợ được khoanh tròn

  • Với một bộ phân lớp tuyến tính \(f(x) = w \cdot x + b\), việc tìm lề cực đại tương đương với giải bài toán:
\[\min_{w, b} \; \frac{1}{2}\|w\|^2 \quad \text{subject to} \quad y_i(w \cdot x_i + b) \geq 1 \; \text{for all } i\]
  • Đây là một bài toán quy hoạch toàn phương lồi, do đó nó có nghiệm toàn cục duy nhất (không có cực tiểu địa phương phải lo lắng).

  • Dữ liệu thực tế hiếm khi khả tách hoàn hảo. SVM lề mềm (Soft-margin SVM) cho phép một số điểm vi phạm lề bằng cách đưa vào các biến slack \(\xi_i \geq 0\):

\[\min_{w, b, \xi} \; \frac{1}{2}\|w\|^2 + C \sum_{i=1}^{n} \xi_i \quad \text{subject to} \quad y_i(w \cdot x_i + b) \geq 1 - \xi_i\]
  • Siêu tham số \(C\) kiểm soát sự đánh đổi: \(C\) lớn phạt nặng các phân lớp sai (khớp chặt hơn, nguy cơ quá khớp), \(C\) nhỏ cho phép nhiều vi phạm hơn (lề rộng hơn, được chính quy hóa nhiều hơn).

  • Tính năng mạnh mẽ nhất của SVM là thủ thuật hạt nhân (kernel trick). Nhiều tập dữ liệu không khả tách tuyến tính trong không gian đặc trưng gốc trở nên khả tách khi được ánh xạ lên một không gian có số chiều cao hơn. Thủ thuật hạt nhân cho phép bạn tính tích vô hướng trong không gian cao chiều đó mà không bao giờ cần tính tường minh phép biến đổi.

  • Một hàm hạt nhân \(K(x_i, x_j) = \phi(x_i) \cdot \phi(x_j)\) thay thế mọi tích vô hướng trong bài toán tối ưu SVM. Hạt nhân phổ biến nhất là hạt nhân Hàm cơ sở xuyên tâm (Radial Basis Function - RBF):

\[K(x_i, x_j) = \exp\!\left(-\gamma \|x_i - x_j\|^2\right)\]
  • Hạt nhân RBF ngầm ánh xạ dữ liệu đến một không gian vô hạn chiều. Tham số \(\gamma\) kiểm soát tầm ảnh hưởng của một điểm huấn luyện đơn lẻ: \(\gamma\) lớn nghĩa là mỗi điểm chỉ ảnh hưởng đến lân cận trực tiếp của nó (nguy cơ quá khớp), \(\gamma\) nhỏ cho các ranh giới mượt hơn.

  • Các hạt nhân phổ biến khác bao gồm hạt nhân đa thức \(K(x_i, x_j) = (x_i \cdot x_j + c)^d\) và hạt nhân tuyến tính \(K(x_i, x_j) = x_i \cdot x_j\) (chính là SVM tiêu chuẩn không có biến đổi nào).

  • Trong thực tế, SVM với hạt nhân RBF là bộ phân lớp thống trị trước khi học sâu lên ngôi. Chúng vẫn hoạt động tốt trên các tập dữ liệu nhỏ đến vừa, đặc biệt khi số đặc trưng lớn so với số mẫu.

  • Mối liên hệ của SVM với chương 02 (ma trận) rất sâu sắc. Bài toán tối ưu thường được giải ở dạng đối ngẫu, nơi nghiệm chỉ phụ thuộc vào các tích vô hướng giữa các ví dụ huấn luyện, chính xác là điều làm cho thủ thuật hạt nhân khả thi. Toàn bộ thuật toán vận hành bằng ngôn ngữ của tích vô hướng và đại số tuyến tính.

  • Tóm tắt bộ công cụ Học Máy cổ điển:

Thuật toán Loại Điểm mạnh chính Điểm yếu chính
Naive Bayes Có giám sát (sinh) Nhanh, hoạt động với ít dữ liệu Giả định độc lập
Cây quyết định Có giám sát Dễ diễn giải Dễ quá khớp
Rừng ngẫu nhiên Có giám sát (tổ hợp) Mạnh mẽ, ít siêu tham số Khó diễn giải hơn
Gradient Boosting Có giám sát (tổ hợp) State-of-the-art trên dữ liệu dạng bảng Chậm hơn, nhiều tinh chỉnh
K-Means Không giám sát (phân cụm) Đơn giản, co giãn Giả định cụm hình cầu
GMM Không giám sát (phân cụm) Gán mềm, hình dạng linh hoạt Nhạy với khởi tạo
SVM Có giám sát Hiệu quả trong không gian cao chiều Chậm trên tập dữ liệu lớn

Bài tập lập trình (dùng CoLab hoặc notebook)

  1. Cài đặt Gaussian Naive Bayes từ đầu. Huấn luyện trên dữ liệu 2D tổng hợp với hai lớp và trực quan hóa ranh giới quyết định. So sánh với triển khai của scikit-learn.

    import jax.numpy as jnp
    import matplotlib.pyplot as plt
    from sklearn.datasets import make_classification
    
    # Generate synthetic data
    X, y = make_classification(n_samples=300, n_features=2, n_redundant=0,
                               n_informative=2, n_clusters_per_class=1, random_state=42)
    X, y = jnp.array(X), jnp.array(y)
    
    # Fit Gaussian Naive Bayes from scratch
    classes = jnp.unique(y)
    params = {}
    for c in classes:
        c = int(c)
        mask = y == c
        X_c = X[mask]
        params[c] = {
            'mean': jnp.mean(X_c, axis=0),
            'var': jnp.var(X_c, axis=0),
            'prior': jnp.sum(mask) / len(y)
        }
    
    def gaussian_log_likelihood(x, mean, var):
        return -0.5 * jnp.sum(jnp.log(2 * jnp.pi * var) + (x - mean)**2 / var)
    
    def predict(X):
        preds = []
        for x in X:
            log_posts = []
            for c in [0, 1]:
                log_post = jnp.log(params[c]['prior']) + gaussian_log_likelihood(
                    x, params[c]['mean'], params[c]['var'])
                log_posts.append(log_post)
            preds.append(jnp.argmax(jnp.array(log_posts)))
        return jnp.array(preds)
    
    # Decision boundary visualisation
    xx, yy = jnp.meshgrid(jnp.linspace(X[:,0].min()-1, X[:,0].max()+1, 200),
                           jnp.linspace(X[:,1].min()-1, X[:,1].max()+1, 200))
    grid = jnp.column_stack([xx.ravel(), yy.ravel()])
    zz = predict(grid).reshape(xx.shape)
    
    plt.figure(figsize=(8, 6))
    plt.contourf(xx, yy, zz, alpha=0.3, cmap='coolwarm')
    plt.scatter(X[y==0, 0], X[y==0, 1], c='#3498db', label='Class 0', edgecolors='k', s=20)
    plt.scatter(X[y==1, 0], X[y==1, 1], c='#e74c3c', label='Class 1', edgecolors='k', s=20)
    plt.title("Gaussian Naive Bayes Decision Boundary")
    plt.legend()
    plt.grid(alpha=0.3)
    plt.show()
    
    accuracy = jnp.mean(predict(X) == y)
    print(f"Training accuracy: {accuracy:.2%}")
    

  2. Xây dựng một cây quyết định phân tách bằng Gini impurity. Cài đặt logic phân tách cho một nút đơn và chỉ ra cách information gain chọn đặc trưng và ngưỡng tốt nhất.

    import jax.numpy as jnp
    
    def gini_impurity(y):
        """Gini impurity of a label array."""
        classes, counts = jnp.unique(y, return_counts=True)
        probs = counts / len(y)
        return 1.0 - jnp.sum(probs ** 2)
    
    def information_gain(y, left_mask):
        """IG from splitting y into left/right by boolean mask."""
        parent_gini = gini_impurity(y)
        left_y, right_y = y[left_mask], y[~left_mask]
        n = len(y)
        if len(left_y) == 0 or len(right_y) == 0:
            return 0.0
        child_gini = (len(left_y)/n) * gini_impurity(left_y) + \
                     (len(right_y)/n) * gini_impurity(right_y)
        return float(parent_gini - child_gini)
    
    def best_split(X, y):
        """Find the feature and threshold that maximise information gain."""
        best_ig, best_feat, best_thresh = -1, None, None
        for feat in range(X.shape[1]):
            thresholds = jnp.unique(X[:, feat])
            for thresh in thresholds:
                mask = X[:, feat] <= float(thresh)
                ig = information_gain(y, mask)
                if ig > best_ig:
                    best_ig, best_feat, best_thresh = ig, feat, float(thresh)
        return best_feat, best_thresh, best_ig
    
    # Example: synthetic data
    from sklearn.datasets import make_classification
    X, y = make_classification(n_samples=100, n_features=4, n_redundant=0, random_state=0)
    X, y = jnp.array(X), jnp.array(y)
    
    feat, thresh, ig = best_split(X, y)
    print(f"Best split: feature {feat}, threshold {thresh:.3f}, info gain {ig:.4f}")
    print(f"Parent Gini: {gini_impurity(y):.4f}")
    mask = X[:, feat] <= thresh
    print(f"Left Gini:   {gini_impurity(y[mask]):.4f} ({int(jnp.sum(mask))} samples)")
    print(f"Right Gini:  {gini_impurity(y[~mask]):.4f} ({int(jnp.sum(~mask))} samples)")
    

  3. Cài đặt K-Means từ đầu với khởi tạo K-Means++. Phân cụm một tập dữ liệu tổng hợp và trực quan hóa các cụm tại mỗi lần lặp.

    import jax
    import jax.numpy as jnp
    import matplotlib.pyplot as plt
    from sklearn.datasets import make_blobs
    
    # Generate synthetic clusters
    X, y_true = make_blobs(n_samples=300, centers=4, cluster_std=0.8, random_state=42)
    X = jnp.array(X)
    
    def kmeans_plus_plus_init(X, K, key):
        """K-Means++ initialisation."""
        n = X.shape[0]
        idx = jax.random.randint(key, (), 0, n)
        centroids = [X[idx]]
        for _ in range(1, K):
            dists = jnp.min(jnp.stack([jnp.sum((X - c)**2, axis=1) for c in centroids]), axis=0)
            probs = dists / jnp.sum(dists)
            key, subkey = jax.random.split(key)
            idx = jax.random.choice(subkey, n, p=probs)
            centroids.append(X[idx])
        return jnp.stack(centroids)
    
    def kmeans(X, K, max_iters=20, key=jax.random.PRNGKey(0)):
        centroids = kmeans_plus_plus_init(X, K, key)
        history = [centroids]
        for _ in range(max_iters):
            # Assign step
            dists = jnp.stack([jnp.sum((X - c)**2, axis=1) for c in centroids])
            labels = jnp.argmin(dists, axis=0)
            # Update step
            new_centroids = jnp.stack([
                jnp.mean(X[labels == k], axis=0) for k in range(K)
            ])
            history.append(new_centroids)
            if jnp.allclose(centroids, new_centroids):
                break
            centroids = new_centroids
        return labels, centroids, history
    
    K = 4
    labels, centroids, history = kmeans(X, K)
    
    # Plot final result
    colors = ['#3498db', '#e74c3c', '#27ae60', '#9b59b6']
    plt.figure(figsize=(8, 6))
    for k in range(K):
        mask = labels == k
        plt.scatter(X[mask, 0], X[mask, 1], c=colors[k], s=20, alpha=0.6)
        plt.scatter(centroids[k, 0], centroids[k, 1], c=colors[k], marker='X',
                    s=200, edgecolors='k', linewidths=1.5)
    plt.title(f"K-Means Clustering (K={K}, {len(history)-1} iterations)")
    plt.grid(alpha=0.3)
    plt.show()
    
    # Compute inertia
    inertia = sum(jnp.sum((X[labels == k] - centroids[k])**2) for k in range(K))
    print(f"Final inertia: {inertia:.2f}")
    

  4. Trình diễn thủ thuật hạt nhân (kernel trick). Chỉ ra rằng một hạt nhân RBF tính tích vô hướng trong không gian cao chiều bằng cách so sánh ma trận hạt nhân với ánh xạ đặc trưng tường minh cho một hạt nhân đa thức.

    import jax.numpy as jnp
    
    # Simple 2D data
    X = jnp.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]])
    
    # Polynomial kernel: K(x,y) = (x·y + 1)^2
    def poly_kernel(X, degree=2, c=1.0):
        return (X @ X.T + c) ** degree
    
    # Explicit degree-2 feature map for 2D: (1, sqrt(2)*x1, sqrt(2)*x2, x1^2, x2^2, sqrt(2)*x1*x2)
    def poly_features(X):
        x1, x2 = X[:, 0], X[:, 1]
        return jnp.column_stack([
            jnp.ones(len(X)),
            jnp.sqrt(2) * x1,
            jnp.sqrt(2) * x2,
            x1 ** 2,
            x2 ** 2,
            jnp.sqrt(2) * x1 * x2
        ])
    
    K_trick = poly_kernel(X)
    phi = poly_features(X)
    K_explicit = phi @ phi.T
    
    print("Kernel trick (polynomial degree 2):")
    print(K_trick)
    print("\nExplicit feature map dot products:")
    print(K_explicit)
    print(f"\nMatrices match: {jnp.allclose(K_trick, K_explicit)}")
    
    # RBF kernel: no finite explicit map exists
    def rbf_kernel(X, gamma=0.5):
        sq_dists = jnp.sum(X**2, axis=1, keepdims=True) + \
                   jnp.sum(X**2, axis=1) - 2 * X @ X.T
        return jnp.exp(-gamma * sq_dists)
    
    K_rbf = rbf_kernel(X)
    print("\nRBF kernel matrix:")
    print(K_rbf)
    print("Diagonal is always 1 (a point is identical to itself)")
    print("Off-diagonal entries decay with distance")