uczenie-maszynowe/wyk/07_Uczenie_nienadzorowane.ipynb
2024-04-25 12:11:06 +02:00

250 KiB
Raw Blame History

Uczenie maszynowe

7. Uczenie nienadzorowane

Wyobraźmy sobie, że mamy następujący problem:

Mamy zbiór okazów roślin i dysponujemy pewnymi danymi na ich temat (długość płatków kwiatów, ich szerokość itp.), ale zupełnie nie wiemy, do jakich gatunków one należą (nie wiemy nawet, ile jest tych gatunków).

Chcemy automatycznie podzielić zbiór posiadanych okazów na nie więcej niż $k$ grup (klastrów) ($k$ ustalamy z góry), czyli dokonać grupowania (klastrowania; analizy skupień) zbioru przykładów.

Jest to zagadnienie z kategorii uczenia nienadzorowanego.

W celu jego rozwiązania użyjemy algorytmu $k$ średnich.

7.1. Algorytm $k$ średnich

# Przydatne importy

import ipywidgets as widgets
import matplotlib.pyplot as plt
import numpy as np
import pandas
import random

%matplotlib inline
# Wczytanie danych (gatunki kosaćców)

data_iris_raw = pandas.read_csv("iris.csv")

# Nie używamy w ogóle kolumny ostatniej kolumny ("Gatunek"),
# ponieważ chcemy dokonać uczenia nienadzorowanego.
# Przyjmujemy, że w ogóle nie dysponujemy danymi na temat gatunku,
# mamy tylko 150 nieznanych roślin.

# Żeby łatwiej pokazać akgorytm k średnich, ograniczmy się tylko do dwóch cech.

data_iris = pandas.DataFrame()
data_iris["x1"] = data_iris_raw["pl"]
data_iris["x2"] = data_iris_raw["sw"]

print(data_iris)
      x1   x2
0    1.4  3.4
1    1.5  3.7
2    5.6  3.1
3    5.1  3.2
4    4.5  2.5
..   ...  ...
145  1.2  4.0
146  1.4  3.5
147  5.8  3.0
148  5.4  3.1
149  5.7  3.3

[150 rows x 2 columns]
# Dla uproszczenia oznaczeń, cały zbiór zapisuję do zmiennej X,
# a jego kolumny (cechy) do zmiennych X1 i X2

X = data_iris.values
X1 = data_iris["x1"].tolist()
X2 = data_iris["x2"].tolist()
# Wykres danych
def plot_unlabeled_data(X1, X2, x1label=r"$x_1$", x2label=r"$x_2$"):
    fig = plt.figure(figsize=(16 * 0.7, 9 * 0.7))
    ax = fig.add_subplot(111)
    fig.subplots_adjust(left=0.1, right=0.9, bottom=0.1, top=0.9)
    ax.scatter(X1, X2, c="k", marker="o", s=50, label="Dane")
    ax.set_xlabel(x1label)
    ax.set_ylabel(x2label)
    ax.margins(0.05, 0.05)
    return fig
# Generowanie wykresu
fig = plot_unlabeled_data(X1, X2, x1label="$x_1$", x2label="$x_2$")
# Odległość euklidesowa
def euclidean_distance(x1, x2):
    return np.linalg.norm(x1 - x2)
# Funkcja kosztu
def cost_function(X, Y, centroids, distance):
    return np.mean([distance(x, centroids[y]) ** 2 for x, y in zip(X, Y)])
# Algorytm k średnich
def k_means(X, k, distance=euclidean_distance):
    history = []
    Y = []

    # Wylosuj centroid dla każdej klasy
    centroids = [
        [random.uniform(X.min(axis=0)[f], X.max(axis=0)[f]) for f in range(X.shape[1])]
        for c in range(k)
    ]
    cost = cost_function(X, Y, centroids, distance)
    history.append((centroids, Y, cost))

    # Powtarzaj, dopóki klasy się zmieniają
    while True:
        distances = [[distance(centroids[c], x) for c in range(k)] for x in X]
        Y_new = [d.index(min(d)) for d in distances]
        if Y_new == Y:
            break
        Y = Y_new
        cost = cost_function(X, Y, centroids, distance)
        history.append((centroids, Y, cost))
        XY = np.asarray(np.concatenate((X, np.matrix(Y).T), axis=1))
        Xc = [XY[XY[:, 2] == c][:, :-1] for c in range(k)]
        centroids = [
            [Xc[c].mean(axis=0)[f] for f in range(X.shape[1])] for c in range(k)
        ]
        cost = cost_function(X, Y, centroids, distance)
        history.append((centroids, Y, cost))

    result = history[-1][1]
    return result, history
# Wykres danych - klastrowanie
def plot_clusters(X, Y, k, centroids=None, cost=""):
    color = ["r", "g", "b", "c", "m", "y", "k"]
    fig = plt.figure(figsize=(16 * 0.7, 9 * 0.7))
    ax = fig.add_subplot(111)
    fig.subplots_adjust(left=0.1, right=0.9, bottom=0.1, top=0.9)

    if not Y:
        ax.scatter(X[:, 0], X[:, 1], c="gray", marker="o", s=25, label="Dane")

    X1 = [[x for x, y in zip(X[:, 0].tolist(), Y) if y == c] for c in range(k)]
    X2 = [[x for x, y in zip(X[:, 1].tolist(), Y) if y == c] for c in range(k)]

    for c in range(k):
        ax.scatter(X1[c], X2[c], c=color[c], marker="o", s=25, label="Dane")
        if centroids:
            ax.scatter(
                [centroids[c][0]],
                [centroids[c][1]],
                c=color[c],
                marker="+",
                s=500,
                label="Centroid",
            )

    ax.set_xlabel(r"$x_1$")
    ax.set_ylabel(r"$x_2$")
    ax.annotate(
        f"koszt: {cost:.3f}",
        xy=(1, 0),
        xycoords="axes fraction",
        xytext=(-20, 20),
        textcoords="offset pixels",
        horizontalalignment="right",
        verticalalignment="bottom",
        fontsize=12,
    )
    ax.margins(0.05, 0.05)
    return fig
Y, history = k_means(X, 2)
/home/pawel/.local/lib/python3.10/site-packages/numpy/core/fromnumeric.py:3504: RuntimeWarning: Mean of empty slice.
  return _methods._mean(a, axis=axis, dtype=dtype,
/home/pawel/.local/lib/python3.10/site-packages/numpy/core/_methods.py:129: RuntimeWarning: invalid value encountered in scalar divide
  ret = ret.dtype.type(ret / rcount)
fig = plot_clusters(X, Y, 2, centroids=history[-1][0], cost=history[-1][2])
# Przygotowanie interaktywnego wykresu

MAXSTEPS = 15

slider_k = widgets.IntSlider(
    min=1, max=7, step=1, value=2, description=r"$k$", width=300
)


def interactive_kmeans_k(steps, history, k):
    if steps >= len(history) or steps == MAXSTEPS:
        steps = len(history) - 1
    fig = plot_clusters(
        X, history[steps][1], k, centroids=history[steps][0], cost=history[steps][2]
    )


def interactive_kmeans(k):
    slider_steps = widgets.IntSlider(
        min=0, max=MAXSTEPS, step=1, value=0, description=r"steps", width=300
    )
    _, history = k_means(X, k)
    widgets.interact(
        interactive_kmeans_k,
        steps=slider_steps,
        history=widgets.fixed(history),
        k=widgets.fixed(k),
    )
widgets.interact_manual(interactive_kmeans, k=slider_k)
interactive(children=(IntSlider(value=2, description='$k$', max=7, min=1), Button(description='Run Interact', …
<function __main__.interactive_kmeans(k)>

Algorytm $k$ średnich dane wejściowe

  • $k$ liczba klastrów
  • zbiór uczący $X = \{ x^{(1)}, x^{(2)}, \ldots, x^{(m)} \}$, $x^{(i)} \in \mathbb{R}^n$

Na wejściu nie ma zbioru $Y$, ponieważ jest to uczenie nienadzorowane!

Algorytm $k$ średnich pseudokod

  1. Zainicjalizuj losowo $k$ centroidów (środków ciężkości klastrów): $\mu_1, \ldots, \mu_k$.
  2. Powtarzaj dopóki przyporządkowania klastrów się zmieniają:
    1. Dla $i = 1$ do $m$: za $y^{(i)}$ przyjmij klasę najbliższego centroidu.
    2. Dla $c = 1$ do $k$: za $\mu_c$ przyjmij średnią wszystkich punktów $x^{(i)}$ takich, że $y^{(i)} = c$.
# Algorytm k średnich
def kmeans(X, k, distance=euclidean_distance):
    Y = []
    centroids = [
        [random.uniform(X.min(axis=0)[f], X.max(axis=0)[f]) for f in range(X.shape[1])]
        for c in range(k)
    ]  # Wylosuj centroidy
    while True:
        distances = [
            [distance(centroids[c], x) for c in range(k)] for x in X
        ]  # Oblicz odległości
        Y_new = [d.index(min(d)) for d in distances]
        if Y_new == Y:
            break  # Jeśli nic się nie zmienia, przerwij
        Y = Y_new
        XY = np.asarray(np.concatenate((X, np.matrix(Y).T), axis=1))
        Xc = [XY[XY[:, 2] == c][:, :-1] for c in range(k)]
        centroids = [
            [Xc[c].mean(axis=0)[f] for f in range(X.shape[1])] for c in range(k)
        ]  # Przesuń centroidy
    return Y
  • Liczba klastrów jest określona z góry i wynosi $k$.
  • Jeżeli w którymś kroku algorytmu jedna z klas nie zostanie przyporządkowana żadnemu z przykładów, pomija się ją w ten sposób wynikiem działania algorytmu może być mniej niż $k$ klastrów.

Funkcja kosztu dla problemu klastrowania

$$ J \left( y^{(i)}, \ldots, y^{(m)}, \mu_{1}, \ldots, \mu_{k} \right) = \frac{1}{m} \sum_{i=1}^{m} || x^{(i)} - \mu_{y^{(i)}} || ^2 $$

  • Zauważmy, że z każdym krokiem algorytmu $k$ średnich koszt się zmniejsza (lub ewentualnie pozostaje taki sam).

Wielokrotna inicjalizacja

  • Algorytm $k$ średnich zawsze znajdzie lokalne minimum funkcji kosztu $J$, ale nie zawsze będzie to globalne minimum.
  • Aby temu zaradzić, można uruchomić algorytm $k$ średnich wiele razy, za każdym razem z innym losowym położeniem centroidów (tzw. wielokrotna losowa inicjalizacja _multiple random initialization). Za każdym razem obliczamy koszt $J$. Wybieramy ten wynik, który ma najniższy koszt.

Wybór liczby klastrów $k$

Ile powinna wynosić liczba grup $k$?

  • Najlepiej wybrać $k$ ręcznie w zależności od kształtu danych i celu, który chcemy osiągnąć.
  • Możemy też zastosować "metodę łokcia"
# Przygotowanie wykresu

ks = []
costs = []
for k in range(1, 10):
    min_cost = 100000.0
    best_Y = None
    for _ in range(10):  # wielokrotna inicjalizacja
        Y, history = k_means(X, k)
        cost = history[-1][2]
        if cost < min_cost:
            best_Y = Y
            min_cost = cost
    ks.append(k)
    costs.append(min_cost)


def elbow_plot(ks, costs):
    fig = plt.figure(figsize=(16 * 0.7, 9 * 0.7))
    ax = fig.add_subplot(111)
    ax.set_xlabel(r"$k$")
    ax.set_ylabel("koszt")
    ax.plot(ks, costs, marker="o")
/tmp/ipykernel_66762/241557999.py:24: RuntimeWarning: Mean of empty slice.
  centroids = [[Xc[c].mean(axis=0)[f] for f in range(X.shape[1])]
/home/pawel/.local/lib/python3.10/site-packages/numpy/core/_methods.py:121: RuntimeWarning: invalid value encountered in divide
  ret = um.true_divide(
elbow_plot(ks, costs)

7.2. Analiza głównych składowych

Analiza głównych składowych to inny przykład zagadnienia z dziedziny uczenia nienadzorowanego.

Polega na próbie zredukowania liczby wymiarów dla danych wielowymiarowych, czyli zmniejszenia liczby cech, gdy rozpatrujemy przykłady o dużej liczbie cech.

Redukcja liczby wymiarów

Z jakich powodów chcemy redukować liczbę wymiarów?

  • Chcemy pozbyć się nadmiarowych cech, np. „długość w cm” / „długość w calach”, „długość” i „szerokość” / „powierzchnia”.
  • Chcemy znaleźć bardziej optymalną kombinację cech.
  • Chcemy przyspieszyć działanie algorytmów.
  • Chcemy zwizualizować dane.

Błąd rzutowania

Błąd rzutowania błąd średniokwadratowy pomiędzy danymi oryginalnymi a danymi zrzutowanymi.

Sformułowanie problemu

Analiza głównych składowych (_Principal Component Analysis, PCA):

Zredukować liczbę wymiarów z $n$ do $k$, czyli znaleźć $k$ wektorów $u^{(1)}, u^{(2)}, \ldots, u^{(k)}$ takich, że rzutowanie danych na podprzestrzeń rozpiętą na tych wektorach minimalizuje błąd rzutowania.

  • Uwaga: analiza głównych składowych to (mimo pozornych podobieństw) zupełnie inne zagadnienie niż regresja liniowa!

Algorytm PCA

  1. Dany jest zbiór składający się z $x^{(1)}, x^{(2)}, \ldots, x^{(m)} \in \mathbb{R}^n$.
  2. Chcemy zredukować liczbę wymiarów z $n$ do $k$ ($k < n$).
  3. W ramach wstępnego przetwarzania dokonujemy skalowania i normalizacji średniej.
  4. Znajdujemy macierz kowariancji: $$ \Sigma = \frac{1}{m} \sum_{i=1}^{n} \left( x^{(i)} \right) \left( x^{(i)} \right)^T $$
  5. Znajdujemy wektory własne macierzy $\Sigma$ (rozkład SVD): $$ (U, S, V) := \mathop{\rm SVD}(\Sigma) $$
  6. Pierwszych $k$ kolumn macierzy $U$ to szukane wektory.
from sklearn.preprocessing import StandardScaler


# Algorytm PCA - implementacja
def pca(X, k):
    X_std = StandardScaler().fit_transform(X)  # normalizacja
    mean_vec = np.mean(X_std, axis=0)
    cov_mat = np.cov(X_std.T)  # macierz kowariancji
    n = cov_mat.shape[0]
    eig_vals, eig_vecs = np.linalg.eig(cov_mat)  # wektory własne
    eig_pairs = [(np.abs(eig_vals[i]), eig_vecs[:, i]) for i in range(len(eig_vals))]
    eig_pairs.sort()
    eig_pairs.reverse()
    matrix_w = np.hstack([eig_pairs[i][1].reshape(n, 1) for i in range(k)])  # wybór
    return X_std.dot(matrix_w)  # transformacja
data_iris_no_labels = data_iris_raw[["sl", "sw", "pl", "pw"]]
print(data_iris_no_labels)
      sl   sw   pl   pw
0    5.2  3.4  1.4  0.2
1    5.1  3.7  1.5  0.4
2    6.7  3.1  5.6  2.4
3    6.5  3.2  5.1  2.0
4    4.9  2.5  4.5  1.7
..   ...  ...  ...  ...
145  5.8  4.0  1.2  0.2
146  5.1  3.5  1.4  0.3
147  6.5  3.0  5.8  2.2
148  6.9  3.1  5.4  2.1
149  6.7  3.3  5.7  2.1

[150 rows x 4 columns]
import seaborn

# Oryginalne dane (4 cechy  4 wymiary)
seaborn.pairplot(
    data_iris_no_labels, vars=data_iris_no_labels.columns, size=1.5, aspect=1.75
)
/home/pawel/.local/lib/python3.10/site-packages/seaborn/axisgrid.py:2100: UserWarning: The `size` parameter has been renamed to `height`; please update your code.
  warnings.warn(msg, UserWarning)
<seaborn.axisgrid.PairGrid at 0x7f591cd3a710>
transformed_data = pca(data_iris_no_labels, 2)  # dane przekształcone za pomocą PCA
fig = plot_unlabeled_data(transformed_data[:, 0], transformed_data[:, 1])

Analiza głównych składowych umożliwiła stworzenie powyższego wykresu, który wizualizuje 4-wymiarowe dane ze zbioru _iris na 2-wymiarowej płaszczyźnie.

Współrzędne $x_1$ i $x_2$, stanowiące osi wykresu, zostały uzyskane w wyniku działania algorytmu PCA (nie są to żadne z oryginalnych cech ze zbioru _iris ani długość płatka, ani szerokość płatka itp. tylko nowo utworzone cechy).

Tutaj można zobaczyć, jak algorytmy redukcji wymiarów (w tym PCA) działają w praktyce: