Back to Blog

[ML101] Chương 8: Học Không Giám Sát (Unsupervised Learning)

K-Means, DBSCAN và các thuật toán clustering

Bài viết có tham khảo, sử dụng và sửa đổi tài nguyên từ kho lưu trữ handson-mlp, tuân thủ giấy phép Apache‑2.0. Chúng tôi chân thành cảm ơn tác giả Aurélien Géron (@aureliengeron) vì sự chia sẻ kiến thức tuyệt vời và những đóng góp quý giá cho cộng đồng.

Trong các chương trước, chúng ta chủ yếu tập trung vào Học có giám sát (Supervised Learning), nơi mục tiêu là dự đoán nhãn (label) từ dữ liệu đầu vào. Tuy nhiên, trong thực tế, dữ liệu được gán nhãn thường rất ít và tốn kém để tạo ra, trong khi dữ liệu chưa gán nhãn lại vô cùng phong phú. Học không giám sát (Unsupervised Learning) giải quyết vấn đề này bằng cách tìm ra các mẫu (patterns), cấu trúc hoặc tri thức ẩn giấu bên trong dữ liệu mà không cần bất kỳ nhãn nào.

Chương này sẽ đi sâu vào các kỹ thuật quan trọng nhất của học không giám sát, bao gồm:

  1. Phân cụm (Clustering): Gom nhóm các dữ liệu tương đồng nhau.
  2. Phát hiện bất thường (Anomaly Detection): Tìm kiếm các điểm dữ liệu khác biệt so với phần còn lại.
  3. Ước lượng mật độ (Density Estimation): Ước tính hàm phân phối xác suất của dữ liệu.

Bạn có thể chạy trực tiếp các đoạn mã code tại: Google Colab.

Cài đặt môi trường

Trước tiên, vẫn như thường lệ, chúng ta cần đảm bảo môi trường Python và các thư viện cần thiết như Scikit-LearnMatplotlib được cài đặt đúng phiên bản để đảm bảo mã nguồn chạy ổn định.

# Yêu cầu Python 3.10 trở lên
import sys
assert sys.version_info >= (3, 10)

# Yêu cầu Scikit-Learn phiên bản 1.6.1 trở lên
from packaging.version import Version
import sklearn
assert Version(sklearn.__version__) >= Version("1.6.1")

# Thiết lập kích thước font chữ mặc định cho biểu đồ Matplotlib để dễ nhìn hơn
import matplotlib.pyplot as plt

plt.rc('font', size=14)
plt.rc('axes', labelsize=14, titlesize=14)
plt.rc('legend', fontsize=14)
plt.rc('xtick', labelsize=10)
plt.rc('ytick', labelsize=10)

1. Phân cụm (Clustering)

Giới thiệu – Phân loại (Classification) so với Phân cụm (Clustering)

Để hiểu rõ sự khác biệt, hãy xem xét bộ dữ liệu hoa Iris.

  • Bên trái (Phân loại): Mỗi điểm dữ liệu đi kèm với một nhãn cụ thể (loài hoa). Các thuật toán như Logistic Regression hay SVM sẽ tìm đường ranh giới để phân chia các lớp này.
  • Bên phải (Phân cụm): Chúng ta không có nhãn. Dữ liệu chỉ là các điểm trong không gian. Nhiệm vụ của thuật toán phân cụm là tự động phát hiện ra các nhóm dữ liệu tụ tập lại với nhau.
# extra code – đoạn này tạo Hình 8–1
import matplotlib.pyplot as plt
from sklearn.datasets import load_iris

# Tải dữ liệu Iris
data = load_iris()
X = data.data
y = data.target
data.target_names

plt.figure(figsize=(9, 3.5))

# Biểu đồ bên trái: Dữ liệu có nhãn (Classification)
plt.subplot(121)
plt.plot(X[y==0, 2], X[y==0, 3], "yo", label="Iris setosa")
plt.plot(X[y==1, 2], X[y==1, 3], "bs", label="Iris versicolor")
plt.plot(X[y==2, 2], X[y==2, 3], "g^", label="Iris virginica")
plt.xlabel("Petal length")
plt.ylabel("Petal width")
plt.grid()
plt.legend()

# Biểu đồ bên phải: Dữ liệu không nhãn (Clustering)
plt.subplot(122)
plt.scatter(X[:, 2], X[:, 3], c="k", marker=".")
plt.xlabel("Petal length")
plt.tick_params(labelleft=False)
plt.gca().set_axisbelow(True)
plt.grid()

plt.show()

png

Dưới đây là một ví dụ minh họa trước (chúng ta sẽ học kỹ hơn ở phần sau) về việc sử dụng Mô hình Hỗn hợp Gaussian (Gaussian Mixture Model - GMM) để phân tách các cụm này.

GMM có thể tìm ra các cụm khá tốt bằng cách sử dụng tất cả 4 đặc trưng (chiều dài/rộng đài hoa và cánh hoa). Đoạn mã sau ánh xạ (map) mỗi cụm tìm được với loài hoa phổ biến nhất trong cụm đó để so sánh với nhãn thực tế.

# extra code
import numpy as np
from scipy import stats
from sklearn.mixture import GaussianMixture

# Sử dụng GMM để phân cụm
y_pred = GaussianMixture(n_components=3, random_state=42).fit(X).predict(X)

# Ánh xạ từng cụm sang lớp (loài hoa) phổ biến nhất trong cụm đó
mapping = {}
for class_id in np.unique(y):
    mode, _ = stats.mode(y_pred[y==class_id])
    mapping[mode] = class_id

y_pred = np.array([mapping[cluster_id] for cluster_id in y_pred])

# Vẽ kết quả phân cụm
plt.plot(X[y_pred==0, 2], X[y_pred==0, 3], "yo", label="Cluster 1")
plt.plot(X[y_pred==1, 2], X[y_pred==1, 3], "bs", label="Cluster 2")
plt.plot(X[y_pred==2, 2], X[y_pred==2, 3], "g^", label="Cluster 3")
plt.xlabel("Petal length")
plt.ylabel("Petal width")
plt.legend(loc="upper left")
plt.grid()
plt.show()

png

Chúng ta có thể kiểm tra tỷ lệ các mẫu hoa Iris được gán đúng cụm (tương ứng với nhãn thực tế). Kết quả cho thấy độ chính xác rất cao, lên tới khoảng 96.6%.

# Tính tỷ lệ phần trăm mẫu được gán đúng cụm
(y_pred==y).sum() / len(y_pred)
np.float64(0.9666666666666667)

K-Means (K-Trung bình)

K-Means là một thuật toán đơn giản và hiệu quả, có khả năng phân cụm dữ liệu rất nhanh. Ý tưởng chính là tìm ra kk tâm cụm (centroids) và gán mỗi điểm dữ liệu vào tâm cụm gần nhất.

Huấn luyện và Dự đoán

Hãy bắt đầu bằng việc tạo ra một bộ dữ liệu giả lập gồm 5 nhóm (blobs) và huấn luyện mô hình K-Means.

from sklearn.cluster import KMeans
from sklearn.datasets import make_blobs

# extra code – tạo dữ liệu blobs giả lập
blob_centers = np.array([[ 0.2,  2.3], [-1.5 ,  2.3], [-2.8,  1.8],
                         [-2.8,  2.8], [-2.8,  1.3]])
blob_std = np.array([0.4, 0.3, 0.1, 0.1, 0.1])
X, y = make_blobs(n_samples=2000, centers=blob_centers, cluster_std=blob_std,
                  random_state=7)

# Huấn luyện K-Means với k=5
k = 5
kmeans = KMeans(n_clusters=k, random_state=43)
y_pred = kmeans.fit_predict(X)

Sau khi huấn luyện, chúng ta có thể trực quan hóa các cụm mà K-Means đã tìm thấy. Lưu ý rằng thuật toán không biết nhãn thực sự, nó chỉ gom nhóm dựa trên khoảng cách.

# extra code – hàm vẽ biểu đồ các cụm
def plot_clusters(X, y=None):
    plt.scatter(X[:, 0], X[:, 1], c=y, s=1)
    plt.xlabel("$x_1$")
    plt.ylabel("$x_2$", rotation=0)

plt.figure(figsize=(8, 4))
plot_clusters(X)
plt.gca().set_axisbelow(True)
plt.grid()
plt.show()

png

Mỗi điểm dữ liệu đã được gán một nhãn (từ 0 đến 4), đại diện cho chỉ số của cụm mà nó thuộc về. Bạn có thể truy cập các nhãn này thông qua thuộc tính labels_.

y_pred
array([4, 0, 1, ..., 2, 1, 0], dtype=int32)
y_pred is kmeans.labels_
True

Chúng ta cũng có thể xem tọa độ của 5 tâm cụm (centroids) mà thuật toán đã ước lượng được:

kmeans.cluster_centers_
array([[-2.80389616,  1.80117999],
       [ 0.20876306,  2.25551336],
       [-2.79290307,  2.79641063],
       [-1.46679593,  2.28585348],
       [-2.80037642,  1.30082566]])

Một điều cần lưu ý: nhãn cụm (0, 1, 2…) là ngẫu nhiên và phụ thuộc vào thứ tự khởi tạo, chúng không mang ý nghĩa ngữ nghĩa như “nhãn lớp” trong học có giám sát.

kmeans.labels_
array([4, 0, 1, ..., 2, 1, 0], dtype=int32)

Tất nhiên, bạn có thể sử dụng mô hình đã huấn luyện để dự đoán cụm cho các điểm dữ liệu mới:

import numpy as np
X_new = np.array([[0, 2], [3, 2], [-3, 3], [-3, 2.5]])
kmeans.predict(X_new)
array([1, 1, 2, 2], dtype=int32)

Ranh giới Quyết định (Decision Boundaries)

Khi vẽ ranh giới quyết định của K-Means, chúng ta sẽ nhận được một biểu đồ Voronoi (Voronoi diagram). Không gian được chia thành các ô, trong đó mọi điểm trong một ô đều gần tâm cụm của ô đó hơn bất kỳ tâm cụm nào khác.

# extra code – hàm vẽ ranh giới quyết định và tâm cụm
def plot_data(X):
    plt.plot(X[:, 0], X[:, 1], 'k.', markersize=2)

def plot_centroids(centroids, weights=None, circle_color='w', cross_color='k'):
    if weights is not None:
        centroids = centroids[weights > weights.max() / 10]
    plt.scatter(centroids[:, 0], centroids[:, 1],
                marker='o', s=35, linewidths=8,
                color=circle_color, zorder=10, alpha=0.9)
    plt.scatter(centroids[:, 0], centroids[:, 1],
                marker='x', s=2, linewidths=12,
                color=cross_color, zorder=11, alpha=1)

def plot_decision_boundaries(clusterer, X, resolution=1000, show_centroids=True,
                             show_xlabels=True, show_ylabels=True):
    mins = X.min(axis=0) - 0.1
    maxs = X.max(axis=0) + 0.1
    xx, yy = np.meshgrid(np.linspace(mins[0], maxs[0], resolution),
                         np.linspace(mins[1], maxs[1], resolution))
    Z = clusterer.predict(np.c_[xx.ravel(), yy.ravel()])
    Z = Z.reshape(xx.shape)

    plt.contourf(Z, extent=(mins[0], maxs[0], mins[1], maxs[1]),
                cmap="Pastel2")
    plt.contour(Z, extent=(mins[0], maxs[0], mins[1], maxs[1]),
                linewidths=1, colors='k')
    plot_data(X)
    if show_centroids:
        plot_centroids(clusterer.cluster_centers_)

    if show_xlabels:
        plt.xlabel("$x_1$")
    else:
        plt.tick_params(labelbottom=False)
    if show_ylabels:
        plt.ylabel("$x_2$", rotation=0)
    else:
        plt.tick_params(labelleft=False)

plt.figure(figsize=(8, 4))
plot_decision_boundaries(kmeans, X)
plt.show()

png

Phân cụm Cứng (Hard Clustering) so với Phân cụm Mềm (Soft Clustering)

  • Phân cụm cứng: Gán mỗi điểm dữ liệu vào duy nhất một cụm.
  • Phân cụm mềm: Tính toán điểm số (thường là khoảng cách hoặc độ tương đồng) của mỗi điểm dữ liệu đối với tất cả các cụm.

Phương thức transform() trong Scikit-Learn trả về khoảng cách từ điểm dữ liệu đến từng tâm cụm (Euclidean distance). Điều này rất hữu ích khi dùng khoảng cách này làm đặc trưng đầu vào cho các thuật toán khác.

# Tính khoảng cách từ các điểm mới đến 5 tâm cụm
kmeans.transform(X_new).round(2)
array([[2.81, 0.33, 2.9 , 1.49, 2.89],
       [5.81, 2.8 , 5.85, 4.48, 5.84],
       [1.21, 3.29, 0.29, 1.69, 1.71],
       [0.73, 3.22, 0.36, 1.55, 1.22]])

Bạn có thể xác minh rằng kết quả trên chính xác là khoảng cách Euclidean:

# extra code - Kiểm tra thủ công khoảng cách Euclidean
np.linalg.norm(np.tile(X_new, (1, k)).reshape(-1, k, 2)
               - kmeans.cluster_centers_, axis=2).round(2)
array([[2.81, 0.33, 2.9 , 1.49, 2.89],
       [5.81, 2.8 , 5.85, 4.48, 5.84],
       [1.21, 3.29, 0.29, 1.69, 1.71],
       [0.73, 3.22, 0.36, 1.55, 1.22]])

Thuật toán K-Means

Thuật toán K-Means hoạt động theo quy trình lặp đi lặp lại rất đơn giản:

  1. Khởi tạo: Chọn ngẫu nhiên kk điểm làm tâm cụm ban đầu (centroids).
  2. Lặp cho đến khi hội tụ (tâm cụm không thay đổi nữa):
    • Gán: Gán mỗi điểm dữ liệu vào tâm cụm gần nhất.
    • Cập nhật: Tính lại vị trí tâm cụm bằng trung bình cộng (mean) của tất cả các điểm thuộc cụm đó.

Dưới đây, chúng ta sẽ mô phỏng quá trình này qua 3 vòng lặp đầu tiên để xem các tâm cụm di chuyển như thế nào.

# extra code – tạo hình 8–4 mô phỏng các bước lặp của K-Means
kmeans_iter1 = KMeans(n_clusters=5, init="random", n_init=1, max_iter=1,
                      random_state=18)
kmeans_iter2 = KMeans(n_clusters=5, init="random", n_init=1, max_iter=2,
                      random_state=18)
kmeans_iter3 = KMeans(n_clusters=5, init="random", n_init=1, max_iter=3,
                      random_state=18)
kmeans_iter1.fit(X)
kmeans_iter2.fit(X)
kmeans_iter3.fit(X)

plt.figure(figsize=(10, 8))

plt.subplot(321)
plot_data(X)
plot_centroids(kmeans_iter1.cluster_centers_, circle_color='r', cross_color='w')
plt.ylabel("$x_2$", rotation=0)
plt.tick_params(labelbottom=False)
plt.title("Update the centroids (initially randomly)")

plt.subplot(322)
plot_decision_boundaries(kmeans_iter1, X, show_xlabels=False,
                         show_ylabels=False)
plt.title("Label the instances")

plt.subplot(323)
plot_decision_boundaries(kmeans_iter1, X, show_centroids=False,
                         show_xlabels=False)
plot_centroids(kmeans_iter2.cluster_centers_)

plt.subplot(324)
plot_decision_boundaries(kmeans_iter2, X, show_xlabels=False,
                         show_ylabels=False)

plt.subplot(325)
plot_decision_boundaries(kmeans_iter2, X, show_centroids=False)
plot_centroids(kmeans_iter3.cluster_centers_)

plt.subplot(326)
plot_decision_boundaries(kmeans_iter3, X, show_ylabels=False)

plt.show()

png

Sự biến thiên của K-Means (K-Means Variability)

Vấn đề lớn nhất của K-Means cơ bản là sự phụ thuộc vào khởi tạo ngẫu nhiên. Nếu xui xẻo chọn phải các tâm cụm ban đầu không tốt, thuật toán có thể hội tụ tại một cực trị địa phương (local optimum) thay vì giải pháp tốt nhất toàn cục.

Dưới đây là ví dụ về hai lần chạy với khởi tạo ngẫu nhiên khác nhau dẫn đến hai kết quả phân cụm khác hẳn nhau (và đều không tối ưu).

# extra code – tạo hình 8–5 so sánh các khởi tạo khác nhau
def plot_clusterer_comparison(clusterer1, clusterer2, X, title1=None,
                              title2=None):
    clusterer1.fit(X)
    clusterer2.fit(X)

    plt.figure(figsize=(10, 3.2))

    plt.subplot(121)
    plot_decision_boundaries(clusterer1, X)
    if title1:
        plt.title(title1)

    plt.subplot(122)
    plot_decision_boundaries(clusterer2, X, show_ylabels=False)
    if title2:
        plt.title(title2)

kmeans_rnd_init1 = KMeans(n_clusters=5, init="random", n_init=1, random_state=2)
kmeans_rnd_init2 = KMeans(n_clusters=5, init="random", n_init=1, random_state=8)

plot_clusterer_comparison(kmeans_rnd_init1, kmeans_rnd_init2, X,
                          "Solution 1",
                          "Solution 2 (with a different random init)")

plt.show()

png

Nếu bạn biết trước vị trí gần đúng của các tâm cụm, bạn có thể truyền chúng vào tham số init để hướng dẫn thuật toán, giúp nó hội tụ đúng chỗ.

# Khởi tạo thủ công các tâm cụm
good_init = np.array([[-3, 3], [-3, 2], [-3, 1], [-1, 2], [0, 2]])
kmeans = KMeans(n_clusters=5, init=good_init, random_state=42)
kmeans.fit(X)
KMeans(init=array([[-3,  3],
       [-3,  2],
       [-3,  1],
       [-1,  2],
       [ 0,  2]]),
       n_clusters=5, random_state=42)