Laboratorium 12 + 13: grupowanie dokumentów tekstowych

Na dwóch kolejnych laboratoriach przeprowadzimy grupowanie dokumentów tekstowych z użyciem 3 metod:

  • k-means
  • EM
  • DBSCAN (i jego wariantu HDBSCAN)

Narzędzia:

  • Docker i docker-compose - do budowy środowiska z zainstalowanymi pakietami i udostępniania jupyter lab
  • duckdb - do zapisu danych i wykonania kwerend SQL
  • spaCy - do wektoryzacji i tokenizacji tekstów

Etapy:

  1. Pobrane zostaną teksty 10000 artykułów z Wikipedii
  2. Zbudowane zostaną ich reprezentacje wektorowe (wektory osadzeń i wektory TF-IDF)
  3. Zastosujemy UMAP do redukcji wymiarów wektorów na potrzeby klasteryzacji i wizualizacji
  4. Do dokumentów zostaną dodane etykiety klasteryzacji dla różnych algorytmów
  5. Porównamy ich wyniki

Budowa obrazu dockera

Pobierz i rozpakuj archiwum clustering-jupyter.zip

Znajduje się tam plik pyproject.toml ze specyfikacją pakietów do zainstalowania

[project]
name = "clustering-nlp"
version = "0.1.0"
description = "Projekt na zajęcia: Eksploracja danych, 2026"
readme = "README.md"
requires-python = ">=3.12"

dependencies = [
    "pandas",
    "tabulate",
    "datasets",            # Pobieranie Wikipedii z Hugging Face
    "spacy",               # NLP i embeddingi statyczne
    "scikit-learn",        # K-means, GMM, DBSCAN
    "hdbscan",             # Warian DBSCAN
    "umap-learn",          # Redukcja wymiarów
    "plotly",              # Interaktywne wykresy
    "nbformat",            
    "tqdm",                # Paski postępu
    "matplotlib",          
    "seaborn",
    "wordcloud",             
    "jupyterlab",          
    "ipywidgets", 
    "jupysql",             # SQL w notatniku
    "duckdb",              # Baza OLAP 
    "duckdb-engine",
    "ollama",              # Opcjonalnie: biblioteka do komunikacji z lokalnym LLM
    "pyarrow",             
    "fastparquet",         
]

Plik Dockerfile

FROM python:3.12-slim

RUN apt-get update && apt-get install -y \
    curl \
    build-essential \
#    pandoc \
    && rm -rf /var/lib/apt/lists/*

# Instalacja uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

WORKDIR /app

COPY pyproject.toml .

# Instalacja zależności przez uv 
RUN uv sync

# Pobranie modelu spaCy
RUN uv run python -m spacy download pl_core_news_lg


WORKDIR /app/notebooks

oraz plik docker-compose.yaml

version: '3.8'

services:
  nlp-jupyter:
    build: .
    image: nlp-jupyter
    container_name: eksploracja_danych_nlp
    ports:
      - "9888:8888"
    volumes:
      - ./notebooks:/app/notebooks
      - ./hf_cache:/root/.cache/huggingface
    shm_size: '4gb'
    deploy:
      resources:
        limits:
          memory: 12gb
    command: >
      uv run jupyter lab 
      --ip=0.0.0.0 
      --allow-root 
      --no-browser 
      --IdentityProvider.token='nlp' 
      --notebook-dir=/app/notebooks

Uruchomienie

  • aby zbudować obraz wydaj komendę docker compose build
  • aby uruchomić kontener wydaj komendę docker compose up
  • Po uruchomieniu otwórz link http://localhost:9888/lab?token=nlp
  • Defaultowym katalogiem dla jupyetera lab jest notebooks. Znajduje się tam notatnik wikipedia.ipynb, w którym można kontynuować implementację kodu

Uwagi: Jako menadżer pakietów stosowany jest uv. Aby dodać pakiet należy w notatniku uruchomić komendę !uv add package

1. Ładowanie danych tekstowych

Kod, który pobiera 10 000 artykułów z Hugging Face znajduje się w notatniku wikipedia.ipynb. Kluczowym elementem jest losowanie wartości OFFSET - numeru dokumentu od którego zostanie ropoczęte pobieranie. Celem jest zróżnicowanie wyników i wizualizacji.

Zapisz korpus po załadowaniu dokumentów.

2. SpaCy i wektory osadzeń (embeddingi)

SpaCy Industrial-Strength Natural Language Processing to zaawansowana biblioteka, stworzona specjalnie z myślą o przemysłowych i produkcyjnych zastosowaniach NLP. W przeciwieństwie do akademickich narzędzi stawiających na mnogość algorytmów, spaCy skupia się na maksymalnej wydajności i dostarczaniu jednego, zoptymalizowanego rozwiązania dla każdego zadania. Za pomocą gotowych, wielojęzycznych modeli statystycznych i neuronowych biblioteka pozwala w ułamku sekundy przeprowadzić pełną analizę tekstu:

  • podziału na tokeny (słowa) i zdania
  • rozpoznawanie części mowy oraz form podstawowych (lematyzację)
  • wykrywanie encji nazwanych (NER), takich jak imiona, daty czy lokalizacje
  • związków składniowych

Ze względu na swoją szybkość, stabilność oraz łatwość integracji z frameworkami głębokiego uczenia, jest to obecnie jedna z najpopularniejszych bibliotek do budowania komercyjnych systemów wyszukiwania semantycznego, chatbotów oraz narzędzi do analizy dużych zbiorów tekstowych.

SpaCy oferuje ciągi przetwarzania dostosowane do konkretnego języka. Użyjemy modelu o dużej dokładności pl_core_news_lg

import spacy
 
nlp = spacy.load("pl_core_news_lg")
 
doc = nlp('Ala ma kota')
print(doc.vector.shape)
for t in doc:
    print(t,t.vector.shape)

W wyniku przetwarzania dokumentu dzielony jest on na tokeny, każdy token ma przypisane rózne atrybuty, w tym wektor osadzeń (o długości 300 dla wybranego wcześniej modelu). Wektory osadzeń tokenów po zsumowaniu dają wektor dokumentu. Wynik:

(300,)
Ala (300,)
ma (300,)
kota (300,)

Dodawanie wektorów osadzeń

Wyznaczanie wektorów osadzeń za pomocą spaCy może być czasochłonną operacją. SpaCy jest zainstalowane w obrazie dockera bez wsparcia dla GPU.

Zarejestrowane czasy to:

  • 5 min z wydajnością 33 dokumentów/sekundę [wariant optymistyczny]
  • 20 min z wydajnością 8.2 dokumentów/sekundę
  • 47 minut z wydajnością 3.5 dokumentów/sekundę [wariant pesymistyczny, bez opcji n_process=-1]

Użyj poniższego kodu.

spacy_pipeline.py
import numpy as np
from tqdm import tqdm
 
processed_data = []
 
texts = df.text.to_list()
titles = df.title.to_list()
 
data_tuples = list(zip(texts, titles))
 
with nlp.select_pipes(enable=["tok2vec", "attribute_ruler", "lemmatizer"]): 
    for i, doc in enumerate(tqdm(nlp.pipe(texts, batch_size=50, n_process=-1), total=len(texts))):
 
        valid_tokens = [
            t for t in doc if not t.is_stop and t.is_alpha
        ]
 
        if valid_tokens:
            valid_vectors = [t.vector for t in valid_tokens]
            doc_vector = np.mean(valid_vectors, axis=0)
        else:
            doc_vector = np.zeros(nlp.vocab.vectors_length)
 
        processed_data.append(
            {
                "title": titles[i],
                "text": texts[i],
                "tokens": [t.lemma_.lower() for t in valid_tokens],  
                "vector": doc_vector,
            }
        )
 
  • W celu przyspieszenia wykonywane są tylko części pipeline, np. nie jest budowany graf syntaktyczny zdania
  • Obliczane są wektory osadzeń dla dokumentów z pominięciem stop words i tokenów zawierających liczby
  • Ewentualnie można zmniejszyć batch_size, np. ustawiając na 20, 10, 5.
  • Opcja n_process=-1 powoduje wykorzystanie wszystkich rdzeni procesora

Zapisz wyniki Po zakończeniu przetwarzania koniecznie zapisz wyniki (format dowolny). SpaCy zwraca wektory numpy. Należy je przekonwertować do postaci list, ponieważ ułatwi to późniejsze przetwarzanie.

df_vec = pd.DataFrame(processed_data)
df_vec['vector'] = df_vec['vector'].apply(lambda x: x.tolist() if isinstance(x, np.ndarray) else x)
 
# Na przykład zapis do Parquet
df_vec.to_parquet('wiki_vect.parquet', index=False)
print("Zapisano do Parquet (wektory jako listy).")

3. Dodawanie rzadkiej wektorowej reprezentacji

Wyznaczymy współczynniki TF-IDF dla słów w dokumentach, przyjmując wielkość słownika 300. W wyniku transformacji każdemu dokumentowi zostanie przypisany 300-elementowy rzadki wektor. Ta reprezentacja nazywana jest Bag of Words (BoW)

$$\text{tfidf}(t, d, D) = \text{tf}(t, d) \cdot \text{idf}(t, D)$$

  • Term Frequency (TF) - to miara częstości termu $t$ w dokumencie $d$

$$\text{tf}(t, d) = \frac{n_{t,d}}{\sum_{k} n_{k,d}}$$

  • Inverse Document Frequency (IDF) - to miara zawartości informacji termu na tle całego korpusu $D$

$$\text{idf}(t, D) = \log \frac{N}{1 + |\{d \in D : t \in d\}|}$$

Gdzie:

  • $N$ – całkowita liczba dokumentów w korpusie,
  • $|\{d \in D : t \in d\}|$ – liczba dokumentów zawierających słowo $t$.

Przetwarzamy dane w DataFrame

  • połącz tokeny spacjami
  • zastosuj tfidf_vec = TfidfVectorizer(max_features=300) (z biblioteki scikit-learn)
  • wynik transformacji (załóżmy, że jest on w zmiennej tfidf_sparse_matrix) zapisz w kolumnie “vector_sparse”. Przekonwertuj do postaci listy
  • zapisz dane

Analiza TF-IDF

Słowa są uporządkowane alfabetycznie features = tfidf_vec.get_feature_names_out()

Sprawdź wizualnie, że ta reprezentacja jest rzeczywiście rzadka:

import matplotlib.pyplot as plt
import pandas as pd
 
sample_matrix = tfidf_sparse_matrix[:20, :].toarray()
 
plt.figure(figsize=(8, 6))
plt.spy(tfidf_sparse_matrix[:20, :], precision=0.01, aspect='auto', markersize=1)
plt.title("Rozkład niezerowych wartości (Sparsity Pattern)")
plt.show()
 
plt.figure(figsize=(8, 8))
plt.imshow(sample_matrix, aspect='auto', cmap='viridis')
plt.colorbar(label='TF-IDF Score')
plt.xlabel("Indeks Słowa (Feature Index)")
plt.ylabel("Indeks Dokumentu")
plt.title("Fragment macierzy TF-IDF (pierwsze 20 dokumentów)")
plt.show()

Wypisz pierwszych 20 słów o najwyższych wartościach TF-IDF

weights = tfidf_sparse_matrix.sum(axis=0).A1
vocab = tfidf_vec.get_feature_names_out()

Oczekiwany wynik - podobny do poniższej tabeli:

word score
165 polski 613.196
195 rok 581.063
183 przykład 447.919
242 ur 445.775
285 zm 428.274
151 pierwszy 376.847
78 język 376.714
289 zobaczyć 374.653
123 na 356.303
67 imię 348.4
164 polska 346.943
250 wiek 335.809
111 miasto 323.284
185 przypis 323.147
129 nazwa 305.784
25 część 299.625
22 czas 299.144
290 zostać 290.528
34 duży 289.69
219 stosować 283.088

Wartości idf dla słów

TODO Utwórz obiekt pandas dataframe z kolumnami word_index, word oraz idf Słowa odczytujemy za pomocą funkcji tfidf_vec.get_feature_names_out(). Indeks słowa to numer na liście, a wartość idf odczytamy z atrybutu wynikowego tfidf_vec.idf_.

Przykładowy wynik:

word_index word idf_score
0 0 akademicki 3.96433
1 1 aktor 3.73655
2 2 aktorka 3.85433
3 3 amerykański 2.86831
4 4 andrzej 3.30269
5 5 angielski 2.93112
6 6 arcybiskup 3.78557
7 7 armia 3.04959
8 8 austriacki 3.57318
9 9 autor 2.95414

Zapis wszystkich danych do duckdb

Zakładamy że df (typu pandas.DataFrame) zawiera wynik przetwarzania, a df_vocab słownik z wartościami idf.

Wewnątrz kwerend duckdb mozna odwoływać się do obiektów pandas.DataFrame tak, jakby były widokami. Za pomocą poniższego kodu można zapisać dane w duckdb.

import duckdb
 
con = duckdb.connect('wikipedia.duckdb')
 
# Tworzymy tabelę bezpośrednio z DataFrame
con.execute("CREATE OR REPLACE TABLE wikipedia_corpus AS SELECT * FROM df")
con.execute("CREATE OR REPLACE TABLE vocabulary AS SELECT * FROM df_vocab")
 
print(con.execute("SHOW TABLES").fetchall())
con.close()

4. Kwerendy wektorowe

Napiszemy funkcję, do której zostanie przekazany tekst jako argument, a następnie zwróci ona wskazaną liczbę n_samples najbardziej podobnych dokumentów. Wpierw dokonywana jest zamiana tekstu na reprezentację spaCy, a następnie odczytywane wektory osadzeń i tokeny. Funkcja ma trzy tryby działania (opis w docstringu):

  • vector - wykorzystuje wektory osadzeń
  • token - na podstawie zbiorów słów
  • hybrydowy - łączący oba

Implementacja trybu hybrydowego jest podana.

TODO Należy zaimplementować tryby: wektorowy i oparty na tokenach.

import pandas as pd
import numpy as np
 
def find_nearest_documents(con, nlp, query_text: str, n_samples: int = 10, mode: str = "vector") -> pd.DataFrame:
    """
    Funkcja wyszukuje najbardziej podobne dokumenty w bazie DuckDB na podstawie tekstu zapytania.
 
    @param con - połączenie do bazy DuckDB zawierającej tabelę 'wikipedia_corpus'
    @param nlp - zainicjalizowany pipeline SpaCy (np. pl_core_news_md lub pl_core_news_trf)
    @param query_text - tekst zapytania, dla którego szukamy podobnych dokumentów
    @param n_samples - liczba zwracanych najbliższych dokumentów
    @param mode - tryb wyszukiwania: 
                  "vector"  - oblicza odległość cosinusową między osadzeniami (embeddings)
                  "tokens"  - wyszukuje na podstawie pokrycia (części wspólnej) tokenów tekstowych
                  "hybrid"  - łączy podejście wektorowe i tokenowe przy użyciu normalizacji rangowej
    @returns pandas.DataFrame zawierający n_samples najbardziej podobnych dokumentów wraz z miarą dopasowania
    """
    # Przetwarzanie tekstu zapytania za pomocą SpaCy
    doc = nlp(query_text)
 
    # Wyciągamy wektor 
    query_vector = doc.vector.tolist()
 
    # Wyciągamy czyste tokeny (odrzucając stop-words i interpunkcję)
    query_tokens = [token.lemma_.lower() for token in doc if not token.is_stop and not token.is_punct]
 
    if mode == "vector":
        # Używamy LIST_COSINE_SIMILARITY, która przyjmuje zwykłe listy (FLOAT[])
        pass
 
    elif mode == "tokens":
        # Szukamy dokumentów, które mają najwięcej wspólnych słów z zapytaniem
        pass
 
    elif mode == "hybrid":
        # Pobieramy szerszy zestaw kandydatów z obu metod (np. 10x więcej niż n_samples)
        oversample = n_samples * 10
 
        # Pobranie danych metodą wektorową (SELECT * już zawiera wszystkie potrzebne kolumny)
        df_vec = con.execute(f"""
            SELECT *,
                   1 - LIST_COSINE_SIMILARITY(vector::FLOAT[], $1::FLOAT[]) as distance
            FROM wikipedia_corpus
            ORDER BY distance ASC
            LIMIT $2
        """, [query_vector, oversample]).df()
 
        # Pobranie danych metodą tokenową
        df_tok = con.execute(f"""
            SELECT *, 
                   LEN(ARRAY_INTERSECT(tokens, $1::VARCHAR[])) as score 
            FROM wikipedia_corpus 
            ORDER BY score DESC 
            LIMIT $2
        """, [query_tokens, oversample]).df()
 
        # Przypisanie rang (pozycji) w obu rankingach
        df_vec['rank_vec'] = df_vec.index + 1
        df_tok['rank_tok'] = df_tok.index + 1
 
        # Łączenie zestawów danych na podstawie kolumny 'title'
        hybrid_df = pd.merge(df_vec[['title', 'rank_vec']], df_tok[['title', 'rank_tok']], on='title', how='outer')
        hybrid_df.fillna(oversample + 1, inplace=True)
 
        # Obliczanie wyniku hybrydowego (Reciprocal Rank Fusion)
        hybrid_df['hybrid_score'] = (1 / (60 + hybrid_df['rank_vec'])) + (1 / (60 + hybrid_df['rank_tok']))
        hybrid_df = hybrid_df.sort_values(by='hybrid_score', ascending=False).head(n_samples)
 
        # Wyciągnięcie listy zwycięskich tytułów
        target_titles = hybrid_df['title'].tolist()
 
        # 7. Szybkie odzyskanie pełnych danych bez ponownego odpytywania bazy SQL:
        # Łączymy wyniki z df_vec (lub df_tok), odfiltrowując je tylko do najlepszych tytułów.
        # Używamy kombinacji z df_vec i df_tok na wypadek, gdyby jakiś dokument był tylko w jednym z nich.
        combined_all_data = pd.concat([df_vec, df_tok]).drop_duplicates(subset=['title'])
        final_df = combined_all_data[combined_all_data['title'].isin(target_titles)].copy()
 
        # Opcjonalnie: sortujemy finalny DataFrame dokładnie w takiej kolejności, jaką wyznaczył algorytm hybrydowy
        final_df['title'] = pd.Categorical(final_df['title'], categories=target_titles, ordered=True)
        return final_df.sort_values('title').reset_index(drop=True)        
    else:
        raise ValueError(f"Nieznany tryb wyszukiwania: {mode}. Wybierz 'vector', 'tokens' lub 'hybrid'.")

Reciprocal Rank Fusion (RRF)

W trybie “hybrid” stosowany jest algorytm Reciprocal Rank Fusion (RRF). Jest on często używany w systemach wyszukiwania informacji (Information Retrieval) do łączenia wyników z kilku różnych wyszukiwarek.

Dla każdego dokumentu ze zbioru wszystkich znalezionych dokumentów, oblicza sumaryczną ocenę według poniższego wzoru: $$ RRF\_Score(d \in D) = \sum_{m \in M} \frac{1}{k + r_m(d)} $$ Gdzie:

  • $M$ – zestaw systemów wyszukiwania (np. $m_1$ = wyszukiwanie wektorowe, $m_2$ = wyszukiwanie tokenowe),
  • $r_m(d)$ – ranga (pozycja) dokumentu $d$ na liście zwróconej przez system $m$ (pierwsze miejsce to $1$, drugie to $2$, itd.)
  • $k$ – stała wygładzająca (stała regularyzacyjna). Standardowo w literaturze i systemach takich jak Elasticsearch czy Qdrant przyjmuje się $k = 60$

4.1 Przykład wywołania

if 'nlp' not in globals():
    import spacy
    nlp = spacy.load("pl_core_news_lg")
 
import duckdb
 
def check_if_active(connection_var_name):
    if connection_var_name not in globals():
        return False
    try:
        globals()[connection_var_name].execute("SELECT 1;")
        return True
    except:
        return False
 
if not check_if_active("con"):
    con = duckdb.connect("wikipedia.duckdb")
 
query_text = "Kraków to miasto połozone nad rzeką Wisłą w południowej Polsce. Zostało założone przez księcia Kraka w IX wieku. "
df_result  = find_nearest_documents(con, nlp, query_text, mode="hybrid")
df_selected = df_result[["title","text"]]
df_selected['text'] = df_selected.text.apply(lambda r: r[:80])
print(df_selected.to_markdown(index=False))

Przykładowy wynik:

| title                | text                                                                             |
|:---------------------|:---------------------------------------------------------------------------------|
| Grody Czerwieńskie   | Grody Czerwieńskie, Ziemia czerwieńska (, ) – przyjęta w historiografii nazwa zi |
| Goci                 | Goci, Gotowie (, Gutþiuda; , u Pliniusza Starszego Gutones, u Tacyta Gotones; )  |
| Antwerpia            | Antwerpia (, wym. []; , wym. []) – miasto w północnej Belgii, w Regionie Flamand |
| Przemyśl             | Przemyśl (, , ) – miasto na prawach powiatu w południowo-wschodniej Polsce, w wo |
| Baranów Sandomierski | Baranów Sandomierski () – miasto w Polsce, w województwie podkarpackim, w powiec |
| Wolin (wyspa)        | Wolin () – przybrzeżna wyspa należąca do Polski (powiat kamieński oraz Świnoujśc |
| Włocławek            | Włocławek (, ) – miasto w centralnej Polsce, na prawach powiatu. Trzecie co do w |
| Oświęcim             | Oświęcim (, Oszpicin, ) – miasto w województwie małopolskim, siedziba władz powi |
| Konstantynopol       | Konstantynopol () – nazwa Bizancjum nadana miastu przez Konstantyna Wielkiego, k |
| 3 maja               | Święta Imieniny obchodzą: Aleksander, Alodia, Antonina, Diodor, Diodora, Juwenal |

4.2 Oceń jakość wyszukiwania

TODO Przygotuj 5 przykładów tekstów o różnej tematyce. Następnie znajdź 10 najbliższych dokumentów w 3 trybach. Oceń poziom dopasowania licząc $precision@10$

$$precision@10=\frac{\text{Liczba dopasowanych dokumentów na liście top 10}}{10}$$

Wyniki zbierz w tabelce.

5. Redukcja wymiarów UMAP

Zastosujemy redukcję wymiarów, aby:

  • wyświetlać punkty reprezentujących dokumenty na wykresach 2D
  • dokonać konwersji do postaci wektorów o rozmiarach 10 i 20 w celu dalszego przeprowadzenia na nich grupowania

UMAP (Uniform Manifold Approximation and Projection) to metoda redukcji wymiarowości oparta na założeniu, że dane wysokowymiarowe leżą na rozmaitości (manifold) o niższym wymiarze, którą można lokalnie aproksymować. Algorytm buduje ważony graf sąsiedztwa w przestrzeni wejściowej, wykorzystując najbliższych sąsiadów.

Dla punktów $x_i$ i $x_j$ prawdopodobieństwo sąsiedztwa jest definiowane jako: $$ p_{ij} = \exp\left(-\frac{\max(0, d(x_i,x_j)-\rho_i)}{\sigma_i}\right) $$

gdzie $\rho_i$ odpowiada odległości do najbliższego sąsiada zapewniającej lokalną spójność, a $\sigma_i$ jest parametrem normalizującym dobieranym tak, aby liczba efektywnych sąsiadów była zgodna z parametrem n_neighbors.

W przestrzeni niskowymiarowej UMAP konstruuje analogiczny graf z prawdopodobieństwami: $$ q_{ij} = \frac{1}{1 + a \|y_i - y_j\|^{2b}} $$ gdzie $y_i$ i $y_j$ są reprezentacjami punktów po redukcji wymiaru, a parametry a,b kontrolują kształt funkcji odległości.

Algorytm minimalizuje funkcję celu będącą krzyżową entropią pomiędzy strukturą wysokowymiarową i niskowymiarową:

$$ \mathcal{L} = \sum_{i \neq j} \left[ p_{ij}\log\frac{p_{ij}}{q_{ij}} + (1-p_{ij})\log\frac{1-p_{ij}}{1-q_{ij}} \right] $$

Minimalizacja tej funkcji powoduje, że punkty będące sąsiadami w przestrzeni wejściowej pozostają blisko siebie po projekcji, przy jednoczesnym odpychaniu punktów niesąsiednich.

5.1 UMAP 2D

Wykonaj poniższy kod (po załadowaniu danych do dataframe df.

Wydaje się, że algorytm pozostawia dużo drobnoziarnistych obiektów w pamięci, dlatego po jego wywołaniu został dodany kod do wymuszenia grabage collection.

import umap
reducer = umap.UMAP(n_neighbors=30, n_components=2, metric='cosine', random_state=42)
umap_results = reducer.fit_transform(list(df['vector']))
 
df['umap_x'] = umap_results[:, 0]
df['umap_y'] = umap_results[:, 1]
 
# garbage collection
import gc
del reducer
del umap_results
gc.collect()

W podobny sposób przekształć wektory w kolumnie “vector_sparse”.

Wynik można zwizualizować za pomocą plotly:

import plotly.express as px
 
# Szybki podgląd dla Sparse
fig = px.scatter(df, x='umap_x', y='umap_y', hover_name='title', title='UMAP na SpaCy (Dense)')
fig.show()

5.2 Generacja cech (features)

Napisz funkcję według specyfikacji. Możesz w niej uwaględnić także zwolnienie obiektów i uruchomienie garbage colleection.

def add_low_dim_features(df, n_comp, source_column, features_columns):
    """
    Przeprowadza redukcję wymiarów i dodaje wynikową kolumnę do dataframe. 
    @param df - pandas.DataFrame zawierająca kolumnę z wektorami poddanymi redukjci wymiarów
    @param n_comp - wymiary wektorów po redukcji
    @param source_column - kolumna zawierająca wektory wejściowe (tablice float)
    @param features_columns - kolumna do której zostaną umieszczone wynikowe wektory (w postaci list)
    """
    print(f"Generuję UMAP (wymiar = {n_comp}) dla {source_column}...")

oraz wywołaj ją dodając kilka wariantów cech:

add_low_dim_features(df,10, 'vector','umap_10_dense')
add_low_dim_features(df,20, 'vector','umap_20_dense')
add_low_dim_features(df,10, 'vector_sparse','umap_10_sparse')
add_low_dim_features(df,20, 'vector_sparse','umap_20_sparse')
add_low_dim_features(df,2, 'vector','umap_2_dense')
add_low_dim_features(df,2, 'vector_sparse','umap_2_sparse')

Zapisz wynik do duckdb :CREATE OR REPLACE TABLE wikipedia_corpus AS SELECT * FROM df

6. Grupowanie: k-means

Tabela (dataframe lub duckdb) zawiera informacje o dokumentach:

  • tytuły
  • treść
  • tokeny
  • oryginalne cechy - wektory (spaCy i TF-IDF)
  • cechy UMAP (wektory o niższych wymiarach)
  • wektory używane do wyświetlania

Do tego dodamy kolumny z etykietami grupowania przeprowadzonego za pomoca różnie skonfigurowanych algorytmów z uzyciem różnych cech. Przyjmijmy, że nazwa kolumny z grupowaniem będzie kodowała interesujące nas metadane. Na przykład będzie miała postać:

cluster.{algorithm}.{algorithm_params}.{features_column}

Czyli, np. nazwa kolumny “cluster.kmeans.10.vector” koduje informacje, że zawiera ona etykiety grupowania za pomocą algorytmu k-means z parametrem k=10 na cechach vector (czyli pełnych wektorach ze SpaCy. Z kolei “cluster.kmeans.4.umap_20_dense” to wynik k-means dla k=4 na cechach umap_20_dense.

:!: Zwróć uwagę na obecność kropek w nazwach kolumn. W takim przypadku nazwa kolumny w kwerendach SQL musi być zapisana w podwójnych cudzysłowach.

6.1 Przykład i wizualizacja

Dodanie kolumny z grupowaniem

import duckdb
import pandas as pd
import numpy as np
from sklearn.cluster import KMeans
import plotly.express as px
 
con = duckdb.connect('wikipedia.duckdb')
df_tmp = con.execute("SELECT * FROM wikipedia_corpus").df()
con.close()
 
n_clusters = 10
features_column = 'umap_20_dense'
X = np.stack(df_tmp[features_column].values)
kmeans = KMeans(n_clusters=n_clusters, random_state=42, n_init=10)
cluster_column = f'cluster.kmeans.{n_clusters}.{features_column}'
df_tmp[cluster_column] = kmeans.fit_predict(X).astype(str)

Wizualizacja:

fig = px.scatter(
    df_tmp, 
    x='umap_x', 
    y='umap_y', 
    color=str(cluster_column),  # Kolorowanie według klastrów
    hover_name='title',        # Tytuł artykułu po najechaniu
    title=f'K-Means (k={n_clusters}) na {features_column}',
    labels={cluster_column: 'Klaster'},
    template='plotly_white',
    width=1000,
    height=700
)
 
fig.update_traces(marker=dict(size=4, opacity=0.7))
 
fig.show()

Przy wizualizacji za pomocą plotly trzeba ustawić color=str(cluster_column). Wtedy numery klastrów zostaną potraktowane jak wartości kategoryczne i kolory zostaną wybrane z mapy o dyskretnych kolorach. Dla int byłyby to mało wyraziste kolory z mapy ciagłej.

6.2 Dodawanie grupowania

Zaimplementuj funkcję według specyfikacji w docstring

def add_kmeans_clustering(df, n_clusters, features_column, cluster_column):
    """
    Funkcja wywołuje algorytm k-means na wektorach features i dodaje do df kolumnę cluster_column
    z numerami grup
    :param df: wejściowy zbiór danych (pandas DataFrame)
    :param n_clusters: liczba grup
    :param features_column: nazwa kolumny z wektorami obserwacji
    :param cluster_column: nazwa kolumny z numerami grup (zakkodowanymi jako stringi
    :return: df 
    """

Wykorzystaj poniższą funkcję do wyświetlania danych. Oblicza ona centroidy oraz rysuje diagram Woronoja. Zwróć uwagę na to, że grupowanie odbywa się w przestrzeni o większych wymiarach niż 2D, więc grupy mogą nie mieścić się wewnątrz komórek Woronoja 2D.

plot_kmeans_results.py
import numpy as np
import plotly.express as px
import plotly.graph_objects as go
from scipy.spatial import Voronoi
 
 
def plot_kmeans_results(df, cluster_column, title, x_column='umap_x', y_column='umap_y', show_voronoi=False):
 
    x_min, x_max = df[x_column].min(), df[x_column].max()
    y_min, y_max = df[y_column].min(), df[y_column].max()
 
    margin_x = (x_max - x_min) * 0.05
    margin_y = (y_max - y_min) * 0.05
 
    fig = px.scatter(
        df, x=x_column, y=y_column, 
        color=df[cluster_column].astype(str),
        labels={cluster_column: "Klaster"},
        hover_name='title', title=title, template='plotly_white',
        width=1000, height=700
    )
    fig.update_traces(marker=dict(size=4, opacity=0.6))
 
 
    if show_voronoi:
        centroids_2d = df.groupby(cluster_column)[[x_column, y_column]].mean().values
        vor = Voronoi(centroids_2d)
        center = centroids_2d.mean(axis=0)
 
        far_dist = (x_max - x_min) * 5 
 
        for pointidx, simplex in zip(vor.ridge_points, vor.ridge_vertices):
            simplex = np.asarray(simplex)
            if np.all(simplex >= 0):
                v1, v2 = vor.vertices[simplex]
                fig.add_shape(type="line", x0=v1[0], y0=v1[1], x1=v2[0], y1=v2[1],
                              line=dict(color="rgba(0,0,0,0.8)", width=1, dash="dot"))
            else:
                i = simplex[simplex >= 0][0]
                t = centroids_2d[pointidx[1]] - centroids_2d[pointidx[0]]
                t /= np.linalg.norm(t)
                n = np.array([-t[1], t[0]])
                midpoint = centroids_2d[pointidx].mean(axis=0)
                direction = np.sign(np.dot(midpoint - center, n)) * n
                far_point = vor.vertices[i] + direction * far_dist
 
                fig.add_shape(type="line", x0=vor.vertices[i][0], y0=vor.vertices[i][1],
                              x1=far_point[0], y1=far_point[1],
                              line=dict(color="rgba(0,0,0,0.8)", width=1, dash="dot"))
 
 
 
                fig.add_trace(go.Scatter(
                    x=centroids_2d[:, 0], 
                    y=centroids_2d[:, 1],
                    mode='markers', 
                    marker=dict(
                        symbol='x', 
                        size=15, 
                        color='white',      # Białe tło (obrys)
                        line=dict(width=4, color='white') 
                    ),
                    showlegend=False,
                    hoverinfo='skip'
                ))
 
                fig.add_trace(go.Scatter(
                    x=centroids_2d[:, 0], 
                    y=centroids_2d[:, 1],
                    mode='markers', 
                    marker=dict(
                        symbol='x', 
                        size=14, 
                        color='black',      
                        line=dict(width=2) 
                    ),
                    name='Centroidy',
                    showlegend=False,
                    hoverinfo='skip'
                ))
 
                fig.update_layout(
                    xaxis=dict(range=[x_min - margin_x, x_max + margin_x]),
                    yaxis=dict(range=[y_min - margin_y, y_max + margin_y]),
                    # To wymusza, by scatter był pod spodem warstw rysowanych później
                    scattermode='group' 
                )
 
    fig.update_layout(
        xaxis=dict(range=[x_min - margin_x, x_max + margin_x]),
        yaxis=dict(range=[y_min - margin_y, y_max + margin_y])
    )
 
    fig.show()

6.3 Dobór liczby grup

6.3.1 Metoda łokcia

Typową metodą doboru jest metoda łokcia. Zaimplementuj i wywołaj poniższa funkcję

def display_kmeans_elbow_plot(df, features_column, k_range=range(1,20) ):
    """
    Funkcja wywołuje algorytm k-means na wektorach features i tworzy listę wartości funkcji celu
    (atrybut kmeans.inertia_ obiektu kmeans = KMeans(...)). Następnie sporządza wykres zalezności inertia od k.
 
    :param df: wejściowy zbiór danych (pandas DataFrame)
    :param k_range: lista wartości
    :param features_column: nazwa kolumny z wektorami obserwacji
    :param cluster_column: nazwa kolumny z numerami grup (zakkodowanymi jako stringi
 
    """

Wartość punktu załamania można też próbowac wyznaczyć automatycznie na podstawie odległości od linii łączącej krańcowe punkty:

find_elbow.py
def find_elbow(k_range, inertia):
    # Konwersja wejściowych list na tablice NumPy dla bezpieczeństwa
    k_range = np.asarray(k_range)
    inertia = np.asarray(inertia)
 
    # Współrzędne punktów skrajnych
    x1, y1 = k_range[0], inertia[0]
    x2, y2 = k_range[-1], inertia[-1]
 
    # Równanie prostej przechodzącej przez p1 i p2 w postaci ogólnej: Ax + By + C = 0
    # A = y2 - y1, B = -(x2 - x1), C = x2*y1 - y2*x1
    A = y2 - y1
    B = -(x2 - x1)
    C = x2 * y1 - y2 * x1
 
    # Obliczamy odległości dla wszystkich punktów jednocześnie (bez pętli)
    # Wzór: |A*x + B*y + C| / sqrt(A^2 + B^2)
    numerator = np.abs(A * k_range + B * inertia + C)
    denominator = np.sqrt(A**2 + B**2)
    distances = numerator / denominator
 
    # Zwracamy k, dla którego odległość jest największa
    return k_range[np.argmax(distances)]

W przypadku dokumentów tekstowych granica nie jest bardzo wyrazista. Optymalna wartość (tu 4 grupy) też nie zawsze jest użyteczna.

6.3.1 Metoda oparta na ocenie wewnętrznej

Liczbę grup można dobrać na podstawie oceny kształtu dla kolejnych wartości k. Wykorzystamy miarę Silhouette. Napisz funkcję według specyfikacji i wyświetl wykres.

from sklearn.metrics import silhouette_score
import matplotlib.pyplot as plt
 
def display_kmeans_silhouette_plot(df, features_column, k_range=range(2,40,2) ):
    """
    Funkcja wywołuje algorytm k-means na wektorach features i tworzy listę wartości współczynnika Silhouette 
    dla róznych wartości k. Następnie wyświetla jej wykres
 
    Parametry:
    @df - dataframe z kolumną features_column
    @features_column - kolumna z wektorami cech
    k_range - sekwencja wartości
 
    """

6.4 Dodajemy kolumny z etykietami grupowania

Dodaj grupowanie k-means dla takiej siatki (iloczynu kartezzjańskiego) parametrów

k_range = [k_z_elbow, 10, k_z_silhouette]
features = ['vector','vector_sparse','umap_10_dense','umap_10_sparse','umap_20_dense', 'umap_20_sparse']

zapisz wyniki w duckdb.

6.4.1 Wizualizacja

TODO Wyświetl i przeanalizuj kilka (2-3) przykłady grupowania. Interesujące są dokumenty na granicy komórek Woronoja. Oceń ich zgodność tematyczną z dokumentami w grupie.

6.4.2 Chmury słów

TODO Dla wybranego grupowania wyświetl chmury słów dla poszczególnych grup. Użyj gotowej funkcji (przeanalizuj zastosowaną kwerendę). UNNEST odpowiada explode() Sparka lub flatten_map() w Ray.

def display_word_cloud(con,cluster_column,cluster_id ):
 
    # 2. Kwerenda: 
    # - UNNEST(tokens) zamienia listę ['a', 'b'] w osobne wiersze
    # - JOIN z vocabulary pobiera idf_score dla każdego słowa
    # - SUM(idf_score) daje nam wagę znaczenia słowa w danym klastrze
    query = f"""
        SELECT 
            t.word, 
            SUM(v.idf_score) as total_weight
        FROM (
            SELECT UNNEST(tokens) as word 
            FROM wikipedia_corpus 
            WHERE "{cluster_column}" = '{cluster_id}'
        ) t
        JOIN vocabulary v ON t.word = v.word
        GROUP BY t.word
        ORDER BY total_weight DESC
        LIMIT 100
    """
    query = f"""
        SELECT 
            t.word, 
            COUNT(*) as count
        FROM (
            SELECT UNNEST(tokens) as word 
            FROM wikipedia_corpus 
            WHERE "{cluster_column}" = '{cluster_id}'
        ) t
        --- JOIN vocabulary v ON t.word = v.word
        GROUP BY t.word 
        ORDER BY count DESC
        LIMIT 100
    """
 
 
    # Pobieramy wynik jako słownik {słowo: waga}
    result = con.execute(query).fetchall()
    word_weights = {row[0]: row[1] for row in result}
 
    # 3. Generowanie chmury na podstawie wag (Frequencies)
    if word_weights:
        wc = WordCloud(
            width=1000, 
            height=500, 
            background_color='black', 
            colormap='spring',
            max_font_size=150
        ).generate_from_frequencies(word_weights)
 
        # 4. Wyświetlanie
        plt.figure(figsize=(15, 8))
        plt.imshow(wc, interpolation='bilinear')
        plt.axis('off')
        plt.title(f"Tematyka klastra {cluster_id} dla grupowania {cluster_column}", fontsize=20)
        plt.show()
    else:
        print(f"Brak danych dla klastra {cluster_id}")

TODO Napisz komentarz jaka jest przypuszczalna tematyka dokumentów w grupach

6.4.3 Charakterystyczne dokumenty w grupie

Użyjemy (wygenerowanych przez gemini :) ) funkcji, których zadaniem jest wydruk charakterystycznych dokumentów w grupie. Wpierw obliczymy centroidy, a następnie pweną liczbę dokumentów położonych najblizej srodka. Obliczenia zostaną przeprowadzone za pomocą kwerend wykonywanych przez duckdb.

get_cluster_centers.py
import pandas as pd
 
def get_cluster_centers(con, features_column: str, cluster_column: str) -> pd.DataFrame:
    """ 
    Funkcja zwraca pandas DataFrame z kolumnami cluster_column i "centroid" 
    zawierającą pary (cluster_id, list of float).
    """
 
    # Kwerenda wykonuje następujące kroki:
    # 1. UNNEST z WITH ORDINALITY rozbija listę na poszczególne wartości (val) i ich indeksy (idx)
    # 2. Grupuje po klastrze i indeksie wymiaru, licząc średnią (AVG)
    # 3. Składa średnie z powrotem w listę (LIST) zachowując kolejność wymiarów (ORDER BY idx)
 
    query = f"""
        WITH exploded AS (
            SELECT 
                "{cluster_column}",
                UNNEST({features_column}) as val,
                GENERATE_SUBSCRIPTS({features_column}, 1) as idx
            FROM wikipedia_corpus
        ),
        averages AS (
            SELECT 
                "{cluster_column}",
                idx,
                AVG(val) as avg_val
            FROM exploded
            GROUP BY "{cluster_column}", idx
        )
        SELECT 
            "{cluster_column}",
            LIST(avg_val ORDER BY idx) as centroid
        FROM averages
        GROUP BY "{cluster_column}"
        ORDER BY "{cluster_column}"
    """
 
    return con.execute(query).df()

Dalej:

documents_by_cluster.py
def documents_by_cluster(features_column,cluster_column, con=None, n_documents = 20,lead_text_len=100):
    if con is None:
        con_ = duckdb.connect('wikipedia.duckdb')
    else:
        con_ =con
    df_centroids = get_cluster_centers(con_,features_column=features_column, cluster_column=cluster_column)
 
    query=f"""WITH distances AS (
        SELECT 
            s."{cluster_column}" AS cluster,
            s.title,
            -- Skracamy tekst do 100 znaków
            LEFT(s.text, {lead_text_len}) AS lead_text,
            -- Obliczamy kwadrat odległości euklidesowej między wektorem a centroidem
            sqrt(list_reduce(list_transform(
                list_zip(s."{features_column}", c.centroid), 
                x -> (x[1] - x[2]) * (x[1] - x[2])
            ), (a, b) -> a + b)) AS dist_to_centroid
        FROM wikipedia_corpus s
        JOIN df_centroids c ON s."{cluster_column}" = c."{cluster_column}" 
    ),
    ranked AS (
        SELECT *,
               ROW_NUMBER() OVER(PARTITION BY cluster ORDER BY dist_to_centroid ASC) as rank
        FROM distances
    )
    SELECT cluster, rank, title, lead_text, dist_to_centroid
    FROM ranked
    WHERE rank <= {n_documents}
    ORDER BY cluster, rank;"""
    df_docs = con_.execute(query).df()
    if con is None:
        con_.close()
    return df_docs
 
def print_documents_by_cluster(df_docs):
    for cluster_id in sorted(df_docs['cluster'].unique(), key=int):
        print(f"\n{'='*80}")
        print(f" KLASTER {cluster_id} - NAJBLIŻEJ CENTROIDU ".center(80, ' '))
        print(f"{'='*80}")
 
        # Filtrujemy dokumenty dla danego klastra
        subset = df_docs[df_docs['cluster'] == cluster_id].sort_values('rank')
 
        for _, row in subset.iterrows():
            print(f"[{row['rank']}] {row['title']}")
            print(f"    {row['lead_text']}...")
            print("-" * 40)

TODO Wybierz jedno z grupowań oraz kolumnę features. Wydrukuj dokumenty należące do klastra, a następnie spróbuj określić tematykę klastra. Przykładowy wynik: documents_by_cluster.txt.zip

Oceń tematykę grup. Wyniki zbierz w tabelce. Do analizy możesz skorzystać z wybranego narzędzia LLM (przesłać plik z odpowiednim promptem).

7. Grupowanie: EM

7.1 Dodajemy etykiety grup

Napisz funkcję według specyfikacji. Użyj klasy GaussianMixture

def add_em_clustering(df, n_clusters, features_column, cluster_column, covariance_type='full'):
    """
    Funkcja wywołuje algorytm em na wektorach features i dodaje do df kolumnę cluster_column
    z numerami grup
    :param df: wejściowy zbiór danych (pandas DataFrame)
    :param n_clusters: liczba grup
    :param features_column: nazwa kolumny z wektorami obserwacji
    :param cluster_column: nazwa kolumny z numerami grup
    :param covariance_type  {'full', 'tied', 'diag', 'spherical'}
    :return: df 
    """

Dodaj grupowanie z kowariancją full dla takich samych liczb klastrów jak dla k-means, ponieważ ułatwi to porównanie. W nazwie uwzglednij kowariancję:

k_range = [???,10,??]
features = ['umap_10_dense','umap_10_sparse','umap_20_dense', 'umap_20_sparse']
 
for  ...:
   cluster_column = f'cluster.em.{k}.full.{f}' 

Dodaj też grupowanie dla różnych postaci macierzy kowariancji i np. k=10

cov_types = ['tied', 'diag', 'spherical']
k=10
features = 'umap_10_dense'
# zakoduj kowariancję w nazwie
cluster_column = f'cluster.em.{k}.{cov}.{features}' 

7.2 Wizualizacja

Użyj poniższej funkcji do wyświetlania grup oraz aproksymacji elips definiujących kształt klastrów.

plot_em_results.py
import numpy as np
import plotly.graph_objects as go
import plotly.express as px
from sklearn.mixture import GaussianMixture
 
def plot_em_results(df, cluster_column, title, x_column='umap_x', y_column='umap_y', 
                    show_ellipses=True, covariance_type='full'):
    """
    Jeśli parametr show_ellipses=True funkcja aproksymuje elipsę odpowiadającą macierzy kowariancji.
    - covariance_type: 'full' - na podstawie rzeczywistych etykiet
    - covariance_type: ''tied', 'diag', 'spherical' - buduje model GMM na danych dwuwymiarowych
    """
 
    # 2. Podstawowy wykres punktowy (z nowymi etykietami klastrów z przestrzeni 2D)
    fig = px.scatter(
        df, x=x_column, y=y_column, 
        color=df[cluster_column].astype(str),
        labels={cluster_column: "Klaster"},
        hover_name='title', title=f"{title} ({covariance_type})", template='plotly_white',
        width=1000, height=700
    )
    fig.update_traces(marker=dict(size=4, opacity=0.5))
 
    if show_ellipses:
        if covariance_type == 'full':
            clusters = sorted(df[cluster_column].unique(), key=int)
            t = np.linspace(0, 2*np.pi, 100) # Parametr do rysowania obwodu
 
            for cluster_id in clusters:
                points = df[df[cluster_column] == cluster_id][[x_column, y_column]].values
                if len(points) < 5: continue
 
                mean = points.mean(axis=0)
                cov = np.cov(points, rowvar=False)
                vals, vecs = np.linalg.eigh(cov)
 
                # Skalowanie dla 2 odchyleń standardowych (ok. 95% punktów)
                # Używamy pierwiastka z wartości własnych jako promieni
                width, height = 2 * np.sqrt(vals) 
 
                # Obliczanie punktów elipsy z uwzględnieniem obrotu
                # x = mean_x + a*cos(t)*cos(theta) - b*sin(t)*sin(theta)
                # y = mean_y + a*cos(t)*sin(theta) + b*sin(t)*cos(theta)
                theta = np.arctan2(vecs[1, 0], vecs[0, 0])
 
                x_ellipse = mean[0] + width * np.cos(t) * np.cos(theta) - height * np.sin(t) * np.sin(theta)
                y_ellipse = mean[1] + width * np.cos(t) * np.sin(theta) + height * np.sin(t) * np.cos(theta)
 
                # Dodanie elipsy jako ścieżki (go.Scatter z wypełnieniem lub linią)
                fig.add_trace(go.Scatter(
                    x=x_ellipse, y=y_ellipse,
                    mode='lines',
                    line=dict(color='rgba(0,0,0,0.4)', width=1.5, dash='dot'),
                    showlegend=False,
                    hoverinfo='skip'
                ))
 
                # Centroid
                fig.add_trace(go.Scatter(
                    x=[mean[0]], y=[mean[1]],
                    mode='markers',
                    marker=dict(symbol='x', size=10, color='black'),
                    showlegend=False, hoverinfo='skip'
                ))
        else:
            # Pobieramy dane 2D do trenowania EM
            X_2d = df[[x_column, y_column]].values
 
            # Dynamicznie sprawdzamy liczbę klastrów na podstawie unikalnych wartości w kolumnie
            # (Zakładamy, że kolumna zawiera poprawne identyfikatory, np. liczby)
            n_clusters = len(df[cluster_column].unique())
 
            # 1. Uruchamiamy Gaussian Mixture bezpośrednio na danych 2D
            gmm = GaussianMixture(n_components=n_clusters, covariance_type=covariance_type, random_state=42)
            gmm.fit_predict(X_2d)
            t = np.linspace(0, 2*np.pi, 100) # Parametr obwodu
 
            for cluster_id in range(n_clusters):
                # Pobieramy średnią dla danego klastra z modelu GMM
                mean = gmm.means_[cluster_id]
 
                # Ekstrakcja macierzy kowariancji w zależności od covariance_type
                if covariance_type == 'full':
                    cov = gmm.covariances_[cluster_id]
                elif covariance_type == 'tied':
                    cov = gmm.covariances_  # Wspólna macierz dla wszystkich
                elif covariance_type == 'diag':
                    cov = np.diag(gmm.covariances_[cluster_id]) # Tylko wariancje na przekątnej
                elif covariance_type == 'spherical':
                    cov = np.eye(2) * gmm.covariances_[cluster_id] # Taka sama wariancja w każdym kierunku
 
                # Geometria elipsy na podstawie macierzy kowariancji
                vals, vecs = np.linalg.eigh(cov)
 
                # Skalowanie dla 2 odchyleń standardowych (95% prawdopodobieństwa rozkładu)
                width, height = 2 * np.sqrt(vals) 
                theta = np.arctan2(vecs[1, 0], vecs[0, 0])
 
                x_ellipse = mean[0] + width * np.cos(t) * np.cos(theta) - height * np.sin(t) * np.sin(theta)
                y_ellipse = mean[1] + width * np.cos(t) * np.sin(theta) + height * np.sin(t) * np.cos(theta)
 
                # Rysowanie elipsy
                fig.add_trace(go.Scatter(
                    x=x_ellipse, y=y_ellipse,
                    mode='lines',
                    line=dict(color='rgba(0,0,0,0.5)', width=1.5, dash='dot'),
                    showlegend=False, hoverinfo='skip'
                ))
 
                # Rysowanie centroidu wyznaczonego przez EM
                fig.add_trace(go.Scatter(
                    x=[mean[0]], y=[mean[1]],
                    mode='markers',
                    marker=dict(symbol='x', size=10, color='black'),
                    showlegend=False, hoverinfo='skip'
                ))
 
    # Skalowanie osi (dodaje margines, żeby elipsy nie wychodziły poza wykres)
    x_min, x_max = df[x_column].min(), df[x_column].max()
    y_min, y_max = df[y_column].min(), df[y_column].max()
    margin = 0.15
    fig.update_layout(
        xaxis=dict(range=[x_min - (x_max-x_min)*margin, x_max + (x_max-x_min)*margin]),
        yaxis=dict(range=[y_min - (y_max-y_min)*margin, y_max + (y_max-y_min)*margin])
    )
 
    fig.show()

TODO

  • Wyświetl i porównaj kilka przykładów grupowania dla róznych postaci macierzy kowariancji.
  • Porównaj wizualnie aproksymacje klastrów na sparse i dense

7.3 Dobór liczby klastrów

Log-Likelihood Score

W modelu mieszanin gaussowskich (GMM) gęstość prawdopodobieństwa dla pojedynczego punktu $x_i$ definiuje się jako sumę ważoną składowych gaussowskich. Metoda .score(X) w scikit-learn oblicza średni logarytm wiarygodności (log-likelihood) przypadający na jedną próbkę: $$ \text{score}(X) = \frac{1}{N} \sum_{i=1}^{N} \ln \left( \sum_{m=1}^{M} \pi_m \mathcal{N}(x_i \mid \mu_m, \Sigma_m) \right) $$

gdzie:

  • $N$ to liczba próbek,
  • $M$ to liczba klastrów,
  • $\pi_m$ to waga danego klastra,
  • $\mathcal{N}$ to wielowymiarowy rozkład normalny ze średnią $\mu_m$ i macierzą kowariancji $\Sigma_m$.

Im wyższa wartość (bliższa zeru lub dodatnia), tym lepiej model opisuje rozkład danych.

AIC (Akaike Information Criterion)

Kryterium informacyjne Akaikego ocenia jakość modelu, łacząc w ocenie jego dopasowanie i poziom skomplikowania (liczbą parametrów). Preferowane są modele dobrze dopasowane do dancyh, a równocześnie prostsze.

$$ \text{AIC} = 2k - 2\ln(L) $$

gdzie:

  • $L$ to całkowity log-likelihood (czyli $\text{score}(X) \times N$),
  • $k$ to łączna liczba estymowanych parametrów modelu (która rośnie wraz z liczbą klastrów oraz zależy od wybranego covariance_type).

AIC nakłada relatywnie łagodną karę za nadmiar parametrów.

BIC (Bayesian Information Criterion)

Bayesowskie kryterium informacyjne działa podobnie do AIC, lecz wprowadza znacznie surowszą karę za złożoność modelu, która rośnie logarytmicznie wraz z wielkością zbioru danych ($N$):

$$ \text{BIC} = k\ln(N) - 2\ln(L) $$

gdzie

  • $k$ to liczba parametrów,
  • $N$ to liczba próbek,
  • $L$ to całkowity log-likelihood.

Z uwagi na kary, BIC ma tendencję do wskazywania prostszych modeli (mniejszej liczby klastrów) niż AIC.

Metody doboru

  • Minimalizacja AIC/BIC
  • Metoda łokcia zastosowana na AIC lub BIC lub LogLikelihood

Napisz funkcję według specyfikacji

import numpy as np
from sklearn.mixture import GaussianMixture
from tqdm import tqdm
 
def compute_gmm_scores(X, k_range, X_val=None):
    """
    Oblicza metryki dopasowania modelu GMM dla podanego zakresu liczby klastrów.
 
    Funkcja dopasowuje model GaussianMixture dla każdej wartości z zakresu `k_range`
    i zbiera kryteria informacyjne (AIC, BIC) oraz średnie wartości log-likelihood
    zarówno dla zbioru treningowego, jak i opcjonalnego zbioru walidacyjnego.
 
    Args:
        X (array-like of shape (n_samples, n_features)):
            Dane treningowe używane do dopasowania modeli GMM.
        k_range (iterable):
            Zakres lub lista liczb całkowitych określających liczbę komponentów (klastrów)
            do przetestowania (np. `range(2, 20)`).
        X_val (array-like of shape (n_samples_val, n_features), optional):
            Opcjonalne dane walidacyjne używane do obliczenia log-likelihood poza próbą treningową
            w celu wykrycia overfittingu. Domyślnie None.
 
    Returns:
        dict: Słownik zawierający listy obliczonych metryk dla każdej liczby klastrów:
            - 'n': Lista testowanych liczb komponentów (skopiowana z `k_range`).
            - 'aic': Wartości kryterium Akaikego (AIC) dla zbioru treningowego (im mniej, tym lepiej).
            - 'bic': Wartości kryterium Bayesowskiego (BIC) dla zbioru treningowego (im mniej, tym lepiej).
            - 'll': Średnie wartości log-likelihood na próbkę dla zbioru treningowego (im więcej, tym lepiej).
            - 'll_val': Średnie wartości log-likelihood na próbkę dla zbioru walidacyjnego 
              (obecne tylko wtedy, gdy przekazano `X_val`).
    """

TODO

  • Wyświetl wykresy AIC i BIC (dla wybranej kolumny z cechami)
  • Wyświetl wykresy Log Likelihood na zbiorze treningowym i walidacyjnym (zastsosuj podział typu 0.7:0.3)
  • Wybierz liczbę grup k na podstawie miejsca minimum AIC/BIC lub maksimum/nasycenie LL. Jezeli takiego puntu nie będzie zastosuj metodę łokcia. Możesz użyc funkcji ustalającej połozenie punktu łokcia dla k-means
  • Dodaj grupowanie typu full dla wyznaczonego k

8 DBSCAN i HDBSCAN

8.1 Dobór parametrów dla DBSCAN

Wyniki DBSCAN zależą od doboru dwóch parametrów:

  • min_points - minimalnej liczby punktów w sąsiedztwie
  • $\epsilon$ - promienia sąsiedztwa

W celu ich wyznaczenia tworzony jest wykres k-distance.

Zaimplementuj funkcję według specyfikacji:

def plot_multi_k_dist(X, min_pts_values=[10, 15, 20, 25, 30, 35, 40, 45, 50]):
    """
    Funkcja:
    1. Buduje reprezentację ball tree: tree = BallTree(X, metric="euclidean")
    2. Wykonuje: tree.query(X, k=?) aby pobrać informacje o odległosciach do k najbliższych sąsiadów
    3. Sortuje odległosci (uwaga, uwzględnia min_pts-1 odległości)
    4. Sporządza wykres posortowanych odległosci
 
    @param: X - macierz punktów (obserwacje w wierszach)
    @param: min_pts_values - wartosci min_pts, dla których należy sporządzić wykresy 
    """

Przykładowy wynik (z użyciem plotly)

Następnie dla wybranej liczby min_points:

  • znajdź odpowiedni wykres
  • odczytaj $\epsilon$ z punktu przegięcia (elbow)

8.2 Dodajemy grupowanie DBSCAN

Napisz funkcję według specyfikacji:

def add_DBSCAN_clustering(df, cluster_column,eps,min_samples):
    """
    Funkcja wywołuje algorytm DBSCAN na wektorach features i dodaje do df kolumnę cluster_column
    z numerami grup
    :param df: wejściowy zbiór danych (pandas DataFrame)
    :param cluster_column: nazwa kolumny z numerami grup
    :param eps - wielkość sąsiedztwa
    :param min_samples - minimalna liczba obserwacji w sąsiedztwie
    :return: df 
    """

Za jej pomocą dodaj kilka grupowań dla wybranego wektora cech features. Pamiętaj aby odpowiednio zakodować metadane w nazwie kolumny z etykietami grupowania:

cluster_column = f"cluster.dbscan.k_{p['min_samples']}.eps_{p['eps']}.{features_column}" 

Wyniki zapisz w bazie duckdb.

8.3 Wykresy

Wyświetlimy wykresy za pomocą poniższej funkcji. Jest ona przystosowana do wyświetlania etykiet -1 kolorem spoza mapy kolorów (jasnoszarym). DBSCAN oznacza etykietami -1 wartości odstające, bez przypisanej grupy.

def plot_results(df, cluster_column, title="", x_column='umap_x', y_column='umap_y'):
    n_clusters = len(df[cluster_column].unique()) - (1 if '-1' in df[cluster_column].values else 0)
    noise_pct = (df[cluster_column] == -1).mean() * 100
 
    fig = px.scatter(
        df, 
        x=x_column, 
        y=y_column, 
        color=df[cluster_column].astype(str),
        color_discrete_map={'-1': 'rgb(200,200,200)'}, 
        hover_name='title',        # Tytuł artykułu po najechaniu
        title=title 
            + f'({cluster_column})' 
            + f"<br><sup>Liczba klastrów: {n_clusters} | Szum: {noise_pct:.1f}%</sup>",
        labels={cluster_column: 'Klaster'},
        template='plotly_white',
        width=1000,
        height=700
    )
 
    fig.update_traces(marker=dict(size=4, opacity=0.7))
 
    fig.show()

TODO Wyświetl wykresy dla kilku wyników grupowania. Porównaj z wynikami dla k-means i EM.

8.3 HDBSCAN

HDBSCAN to wariant algorytmu DBSCAN wykorzystujący hierachiczne grupowanie aglomeracyjne.

DBSCAN zakłada, że wszystkie klastry w zbiorze mają zbliżoną gęstość zdefiniowaną sztywno za pomocą paramentów min_points i $\epsilon$. Jeśli w danych wystęopują klastry bardzo ciasne oraz klastry mocno rozproszone, DBSCAN nie wykryje ich jednocześnie – albo te rzadkie uzna za szum, albo te gęste zleje w jeden wielki konglomerat.

HDBSCAN potrafi wykrywać klastry o różnej gęstości. Nie potrzebuje parametru $\epsilon$. Zamiast tego buduje drzewo hierarchiczne metodą aglomeracyjną (od pojedynczych punktów do jednego wielkiego klastra) i automatycznie sprawdza, które zagęszczenia na różnych poziomach drzewa są stabilne i zasługują na przekształcenie w grupę.

Podstawowe parametry:

  • min_cluster_size - minimalny rozmiar grupy
  • min_samples - odpowiada za obliczenie (zmiennych) promieni sąsiedztwa dla indywidualnych punktów. Zamiast sztywnego $\epsilon$ dla wszystkich punktów, jego wartość obliczana jest indywidualnie. Jeżeli nie podamy min_samples, algorytm przyjmuje min_samples=min_cluster_size

Zaimplementuj funkcję według specyfikacji:

import hdbscan
 
def add_HDBSCAN_clustering(df, cluster_column,min_cluster_size,min_samples):
    """
    Funkcja wywołuje algorytm em na wektorach features i dodaje do df kolumnę cluster_column
    z numerami grup
    :param df: wejściowy zbiór danych (pandas DataFrame)
    :param cluster_column: nazwa kolumny z numerami grup
    :param min_cluster_size - minimalny rozmiar klastra
    :param min_samples - minimalna liczba obserwacji w sąsiedztwie
    :return: df 
    """

TODO Następnie użyj funkcji add_HDBSCAN_clustering do dodania kilku grupowań HDBSCAN z różnymi parametrami min_cluster_size,min_samples (ten drugi może być None) dla wybranych cech. Nie zapomnij o kodowaniu:

cluster_column = f“cluster.hdbscan.k_{p['min_samples']}.s_{p['min_cluster_size']}.{features_column}”

Wyświetl wykresy za pomocą funkcji plot_results()

TODO Porównaj rezultaty zastosowania DBSCAN i HDBSCAN

9. Ocena grupowania

9.1 Metody oceny wewnetrznej

Zastosujemy metody oceny wewnętrznej wykorzystujące wartości cech ($X\in \mathbb{R}^n$) oraz etykiety grupowania.

Dla każdej kolumny z etykietami grupowania (zaczynającymi się od cluster) wyekstrahujemuy z jej nazwy:

  • nazwę metody grupowania
  • nazwę kolumny z cechami
  • nazwę kolumny z etykietami

Oczekiwany wynik:

[{'method': 'kmeans',
  'features': 'vector',
  'clustering': 'cluster.kmeans.4.vector'},
 {'method': 'kmeans',
  'features': 'vector',
  'clustering': 'cluster.kmeans.10.vector'},
  ...

Dalej wykorzystamy podana poniżej funkcję evaluate_clusterings() do wyznaczenia trzech metryk:

  • Silhouette
  • Daviesa-Bouldina
  • Calinskiego Harabasza

W zasadzie każda z nich wyraża oczekiwanie, że grupy powinny skupiać punkty połozone blisko ich środków (centroidów), natomiast same grupy powinny być oddalone od siebie.

evaluate_clusterings.py
from sklearn.metrics import silhouette_score, davies_bouldin_score, calinski_harabasz_score
import pandas as pd
import numpy as np
from tqdm import tqdm
 
def evaluate_clusterings(df, clustering_info):
    results = []
 
    for config in tqdm(clustering_info, desc="Obliczanie metryk"):
        method = config['method']
        feat_col = config['features']
        clust_col = config['clustering']
 
        # 1. Przygotowanie cech (X) - upewniamy się, że to macierz numpy
        if isinstance(df[feat_col].iloc[0], (list, np.ndarray)):
            X = np.stack(df[feat_col].values)
        else:
            # Jeśli to pojedyncze kolumny (np. po UMAP 2D), musisz je najpierw połączyć
            # Zakładając, że feat_col to nazwa kolumny z listą/wektorem:
            X = np.stack(df[feat_col].values)
 
        # 2. Pobranie etykiet klastrów
        labels = df[clust_col].astype(int).values
 
        # 3. Obsługa szumu (-1) dla DBSCAN/HDBSCAN
        # Metryki wymagają co najmniej 2 klastrów i nie radzą sobie dobrze z "szumem" jako klastrem
        # Częstą praktyką jest obliczanie metryk tylko dla punktów, które NIE są szumem
        mask = labels != -1
        if mask.sum() < 2 or len(np.unique(labels[mask])) < 2:
            print(f"Pominięto {clust_col}: zbyt mało klastrów po usunięciu szumu.")
            continue
 
        X_valid = X[mask]
        labels_valid = labels[mask]
 
        # 4. Obliczanie metryk
        # Silhouette: wyższa = lepiej (-1 do 1)
        s_score = silhouette_score(X_valid, labels_valid)
 
        # Davies-Bouldin: niższa = lepiej (lepiej odseparowane i zwarte klastry)
        db_score = davies_bouldin_score(X_valid, labels_valid)
 
        # Calinski-Harabasz: wyższa = lepiej (stosunek dyspersji między i wewnątrz klastrów)
        ch_score = calinski_harabasz_score(X_valid, labels_valid)
 
        results.append({
            'Grupowanie': clust_col,
            'Metoda': method,
            'Cechy': feat_col,
            'Silhouette': round(s_score, 4),
            'Davies-Bouldin': round(db_score, 4),
            'Calinski-Harabasz': round(ch_score, 2),
            'Liczba_punktów': len(labels_valid),
            'Liczba_klastrów': len(np.unique(labels_valid)),
            'Procent_szumu': round((1 - mask.mean()) * 100, 2)
        })
 
    return pd.DataFrame(results)

Rezultatem jest tablica zawierająca różne oceny:

Grupowanie Metoda Cechy Silhouette Davies Bouldin Calinski Harabasz Liczba punktów Liczba klastrów Procent szumu
0 cluster.kmeans.4.vector kmeans vector 0.1298 2.2887 1429.65 10000 4 0
1 cluster.kmeans.10.vector kmeans vector 0.0977 2.3441 778.26 10000 10 0
2 cluster.kmeans.22.vector kmeans vector 0.0976 2.2815 462.87 10000 22 0
3 cluster.kmeans.4.vector_sparse kmeans vector_sparse 0.0448 4.3569 307.8 10000 4 0
4 cluster.kmeans.10.vector_sparse kmeans vector_sparse 0.0524 3.8689 202.49 10000 10 0
5 cluster.kmeans.22.vector_sparse kmeans vector_sparse 0.0763 3.4872 146 10000 22 0
6 cluster.kmeans.4.umap_10_dense kmeans umap_10_dense 0.4165 0.8907 12686.2 10000 4 0
7 cluster.kmeans.10.umap_10_dense kmeans umap_10_dense 0.418 0.8812 12398 10000 10 0
8 cluster.kmeans.22.umap_10_dense kmeans umap_10_dense 0.4429 0.8084 13237.7 10000 22 0
9 cluster.kmeans.4.umap_10_sparse kmeans umap_10_sparse 0.3688 1.0507 4682.37 10000 4 0
10 cluster.kmeans.10.umap_10_sparse kmeans umap_10_sparse 0.4145 0.7772 7584.96 10000 10 0
11 cluster.kmeans.22.umap_10_sparse kmeans umap_10_sparse 0.4322 0.8223 8976.65 10000 22 0
12 cluster.kmeans.4.umap_20_dense kmeans umap_20_dense 0.4173 0.8934 12693.1 10000 4 0
13 cluster.kmeans.10.umap_20_dense kmeans umap_20_dense 0.4088 0.8992 12174.4 10000 10 0
14 cluster.kmeans.22.umap_20_dense kmeans umap_20_dense 0.4404 0.8023 12917 10000 22 0
15 cluster.kmeans.4.umap_20_sparse kmeans umap_20_sparse 0.3659 0.9775 5865.66 10000 4 0
16 cluster.kmeans.10.umap_20_sparse kmeans umap_20_sparse 0.4196 0.7449 7658.42 10000 10 0
17 cluster.kmeans.22.umap_20_sparse kmeans umap_20_sparse 0.4476 0.8269 9210.33 10000 22 0
18 cluster.em.10.full.umap_2_dense em umap_2_dense 0.4156 0.869 14362.9 10000 10 0
19 cluster.em.10.tied.umap_2_dense em umap_2_dense 0.4446 0.7832 16016.5 10000 10 0
20 cluster.em.10.diag.umap_2_dense em umap_2_dense 0.4097 0.8034 14288.7 10000 10 0
21 cluster.em.10.spherical.umap_2_dense em umap_2_dense 0.4438 0.7718 15963.1 10000 10 0
22 cluster.em.10.tied.umap_10_dense em umap_10_dense 0.3533 0.9742 10220.3 10000 10 0
23 cluster.em.10.diag.umap_10_dense em umap_10_dense 0.3908 0.914 11461.9 10000 10 0
24 cluster.em.10.spherical.umap_10_dense em umap_10_dense 0.4051 0.8903 12113.2 10000 10 0
25 cluster.em.4.full.umap_10_dense em umap_10_dense 0.4152 0.899 12593.5 10000 4 0
26 cluster.em.10.full.umap_10_dense em umap_10_dense 0.3856 1.0082 10971.3 10000 10 0
27 cluster.em.22.full.umap_10_dense em umap_10_dense 0.4092 0.8867 11340.5 10000 22 0
28 cluster.em.4.full.umap_10_sparse em umap_10_sparse 0.3548 0.9722 4367.9 10000 4 0
29 cluster.em.10.full.umap_10_sparse em umap_10_sparse 0.4021 0.7856 7341.6 10000 10 0
30 cluster.em.22.full.umap_10_sparse em umap_10_sparse 0.4071 0.8582 8374.95 10000 22 0
31 cluster.em.4.full.umap_20_dense em umap_20_dense 0.4146 0.9011 12599.5 10000 4 0
32 cluster.em.10.full.umap_20_dense em umap_20_dense 0.4099 0.9816 11776.8 10000 10 0
33 cluster.em.22.full.umap_20_dense em umap_20_dense 0.3826 1.0012 10699.8 10000 22 0
34 cluster.em.4.full.umap_20_sparse em umap_20_sparse 0.3592 0.9928 5738.85 10000 4 0
35 cluster.em.10.full.umap_20_sparse em umap_20_sparse 0.3911 0.7674 7095.59 10000 10 0
36 cluster.em.22.full.umap_20_sparse em umap_20_sparse 0.3995 0.9271 8036.23 10000 22 0
37 cluster.dbscan.k_10.eps_0.42.umap_10_dense dbscan umap_10_dense -0.349 1.2194 631.5 9907 9 0.93
38 cluster.dbscan.k_15.eps_0.46.umap_10_dense dbscan umap_10_dense -0.3546 1.2608 564.69 9907 10 0.93
39 cluster.dbscan.k_20.eps_0.49.umap_10_dense dbscan umap_10_dense -0.2943 1.089 719.96 9875 8 1.25
40 cluster.dbscan.k_30.eps_0.6.umap_10_dense dbscan umap_10_dense -0.3661 1.543 310.53 9878 6 1.22
41 cluster.dbscan.k_50.eps_0.7.umap_10_dense dbscan umap_10_dense 0.4143 0.4992 2405.01 9785 3 2.15
42 cluster.dbscan.k_100.eps_0.96.umap_10_dense dbscan umap_10_dense 0.4162 0.4976 2427.22 9922 3 0.78
43 cluster.hdbscan.k_5.s_20.umap_10_dense hdbscan umap_10_dense 0.5391 0.61 8257.26 6809 100 31.91
44 cluster.hdbscan.k_10.s_30.umap_10_dense hdbscan umap_10_dense 0.4734 0.6506 5763.92 6730 66 32.7
45 cluster.hdbscan.k_10.s_50.umap_10_dense hdbscan umap_10_dense 0.3687 0.6721 5151.85 7307 24 26.93
46 cluster.hdbscan.k_15.s_100.umap_10_dense hdbscan umap_10_dense 0.405 0.765 9075.74 7525 18 24.75
47 cluster.hdbscan.k_20.s_200.umap_10_dense hdbscan umap_10_dense 0.438 0.3978 1494.2 9995 2 0.05
  • print(df_results.to_markdown())

TODO Utwórz listy rankingowe i wyznacz najlepsze grupowania metodą Reciprocal Rank Fusion (RRF). Uwzględnij metryki grupowania oraz szum (im wiecej tym gorzej).

Zaimplementuj funkcję add_rrf_ranking() według poniżeszej specyfikacji i wyświetl jej wyniki (np. top 10 w rankingu)

def add_rrf_ranking(df_eval: pd.DataFrame, k: int = 60) -> pd.DataFrame:
    """
    Funkcja oblicza Reciprocal Rank Fusion (RRF) na podstawie indeksów:
    Silhouette, Davies-Bouldin oraz Calinski-Harabasz oraz szumu.
 
    Użyj metody pandas.DataFrame.rank()
 
    * Silhouette: im WIĘCEJ, tym lepiej (ascending=False)
    * Davies-Bouldin: im MNIEJ, tym lepiej (ascending=True)
    * Calinski-Harabasz: im WIĘCEJ, tym lepiej (ascending=False)
    * Szum: im MNIEJ, tym lepiej (ascending=True)
    Dodaje kolumnę 'RRF_Score' oraz 'RRF_Rank' i sortuje DataFrame.
    """

Przykładowy wynik (cechy umap_2_dense niekoniecznie muszą być brane pod uwagę)

RRF Rank RRF Score Grupowanie Silhouette Davies-Bouldin Calinski-Harabasz Procent szumu
1 0.0618701 cluster.em.10.tied.umap_2_dense 0.4446 0.7832 16016.5 0
2 0.0617585 cluster.em.10.spherical.umap_2_dense 0.4438 0.7718 15963.1 0
3 0.0596904 cluster.kmeans.22.umap_10_dense 0.4429 0.8084 13237.7 0
4 0.0595842 cluster.kmeans.22.umap_20_dense 0.4404 0.8023 12917 0
5 0.05777 cluster.em.10.full.umap_2_dense 0.4156 0.869 14362.9 0
6 0.0573715 cluster.em.10.diag.umap_2_dense 0.4097 0.8034 14288.7 0
7 0.0571767 cluster.kmeans.22.umap_20_sparse 0.4476 0.8269 9210.33 0
8 0.0568117 cluster.kmeans.10.umap_20_sparse 0.4196 0.7449 7658.42 0
9 0.0566454 cluster.kmeans.4.umap_20_dense 0.4173 0.8934 12693.1 0
10 0.056562 cluster.kmeans.10.umap_10_dense 0.418 0.8812 12398 0

9.2 V-measure - ocena zewnetrzna

V-measure zakłada, że znamy etykiety klas i na tej podstawie oceniamy jakość grupowania. Zauważmy, że grupowanie nie jest klasyfikacją. Problemem metod oceny zewnętrznej jest permutacja etykiet grupowania względem etykiet klas.

W naszym przypadku nie mamy etykiet klas, ale różne grupowania i możemy okreslić ich podobienstwo za pomocą wartości V-measure.

plot_clustering_similarity_matrix.py
from sklearn.metrics import v_measure_score
import pandas as pd
import numpy as np
import seaborn as sns
import matplotlib.pyplot as plt
from tqdm import tqdm
 
def plot_clustering_similarity_matrix(df, clustering_cols):
    # 1. Inicjalizacja pustej macierzy
    n = len(clustering_cols)
    v_matrix = np.zeros((n, n))
 
    # 2. Obliczanie V-measure dla każdej pary (używa pełnych nazw kolumn)
    for i in tqdm(range(n), desc="Obliczanie V-measure"):
        for j in range(i, n):
            score = v_measure_score(
                df[clustering_cols[i]].astype(str), 
                df[clustering_cols[j]].astype(str)
            )
            v_matrix[i, j] = score
            v_matrix[j, i] = score
 
    # 3. Przygotowanie skróconych nazw poprzez usunięcie "cluster." z początku
    # Jeśli nazwa zaczyna się od 'cluster.', odcinamy to, w innym wypadku zostawiamy oryginalną.
    short_labels = [col.replace('cluster.', '', 1) if col.startswith('cluster.') else col for col in clustering_cols]
 
    # 4. Konwersja do DataFrame dla Seaborn z nowymi, skróconymi etykietami
    v_df = pd.DataFrame(v_matrix, index=short_labels, columns=short_labels)
 
    # 5. Rysowanie wykresu
    plt.figure(figsize=(22, 22)) # Delikatnie powiększone, aby pomieścić 32 kolumny
    sns.set_theme(style="white")
 
    # Tworzymy maskę, aby ukryć górny trójkąt dla czytelności
    mask = np.triu(np.ones_like(v_df, dtype=bool), k=1)
 
    heatmap = sns.heatmap(
        v_df, 
        mask=mask,
        annot=True,           # Wyświetla wartości liczbowe
        fmt=".2f",            # Formatowanie do 2 miejsc po przecinku
        cmap="YlGnBu",        # Przyjemna dla oka paleta kolorów
        linewidths=.5, 
        cbar_kws={"shrink": .7, "label": "V-measure Score"},
        annot_kws={"size": 9} # Zmniejszony font podpisów w kwadracikach, żeby się nie zbiły
    )
 
    plt.title('Podobieństwo klastrowań (V-measure Matrix)', fontsize=18, pad=25, fontweight='bold')
 
    # Obrót etykiet dla lepszej czytelności przy 32 kolumnach
    # plt.xticks(rotation=45, ha='right', fontsize=10)
    plt.xticks(rotation=90,  ha='right', fontsize=10)
    plt.yticks(rotation=0, fontsize=10)
 
    plt.tight_layout()
    plt.show()

Przykładowy wynik:

TODO Przeanalizuj macierz dla Twojego grupowania i określ, które algorytmy lub rodziny algorytmów są do siebie podobne.

TODO Napisz funkcję według specyfikacji i wyświetl top 30 podobnych grupowań

from sklearn.metrics import v_measure_score
import pandas as pd
import numpy as np
from tqdm import tqdm
 
def get_ranked_clustering_pairs(df, clustering_cols):
    """
    Funkcja oblicza podobieństwo V-measure dla każdej unikalnej pary kolumn,
    wykorzystując tqdm do wizualizacji postępu.
    Zwraca DataFrame posortowany od najbardziej do najmniej podobnych par.
    """

Przykładowy wynik:

clustering_1 clustering_2 v_measure
0 kmeans.4.umap_10_dense kmeans.4.umap_20_dense 0.950923
1 kmeans.22.umap_10_dense kmeans.22.umap_20_dense 0.946785
2 dbscan.k_15.eps_0.46.umap_10_dense dbscan.k_20.eps_0.49.umap_10_dense 0.945495
3 dbscan.k_10.eps_0.42.umap_10_dense dbscan.k_15.eps_0.46.umap_10_dense 0.940518
4 em.4.full.umap_10_dense em.4.full.umap_20_dense 0.928894
5 kmeans.4.umap_20_dense em.4.full.umap_20_dense 0.920785
6 kmeans.4.umap_20_dense em.4.full.umap_10_dense 0.91809
7 kmeans.10.umap_10_sparse em.10.full.umap_10_sparse 0.916748
8 kmeans.4.umap_10_dense em.4.full.umap_10_dense 0.91334
9 em.10.tied.umap_2_dense em.10.spherical.umap_2_dense 0.909572
10 kmeans.4.umap_10_dense em.4.full.umap_20_dense 0.903554
11 em.10.full.umap_2_dense em.10.diag.umap_2_dense 0.898759
12 kmeans.22.umap_10_sparse kmeans.22.umap_20_sparse 0.898525
13 dbscan.k_10.eps_0.42.umap_10_dense dbscan.k_20.eps_0.49.umap_10_dense 0.89347
14 dbscan.k_50.eps_0.7.umap_10_dense dbscan.k_100.eps_0.96.umap_10_dense 0.887675
15 kmeans.10.umap_10_dense kmeans.10.umap_20_dense 0.876523
16 em.10.diag.umap_2_dense em.10.spherical.umap_2_dense 0.874419
17 em.4.full.umap_10_sparse em.4.full.umap_20_sparse 0.871628
18 em.10.tied.umap_10_dense em.10.diag.umap_10_dense 0.87059
19 kmeans.22.umap_10_dense em.22.full.umap_10_dense 0.869907
20 dbscan.k_15.eps_0.46.umap_10_dense dbscan.k_50.eps_0.7.umap_10_dense 0.866822
21 kmeans.22.umap_20_sparse em.22.full.umap_10_sparse 0.866341
22 kmeans.22.umap_20_dense em.22.full.umap_10_dense 0.864729
23 em.10.full.umap_10_sparse em.10.full.umap_20_sparse 0.861358
24 kmeans.10.umap_10_sparse kmeans.10.umap_20_sparse 0.854026
25 em.10.full.umap_2_dense em.10.spherical.umap_2_dense 0.85386
26 kmeans.4.umap_20_sparse em.4.full.umap_20_sparse 0.853757
27 em.10.diag.umap_10_dense em.10.spherical.umap_10_dense 0.852873
28 kmeans.22.umap_10_dense em.22.full.umap_20_dense 0.850434
29 kmeans.10.umap_10_dense em.10.spherical.umap_10_dense 0.849702

TODO Oceń co raczej przesądza o podobieństwie:

  • algorytm
  • jego parametry
  • cechy (rozmiary wektorów, dense vs. sparse)

Które algorytmy dają podobne rezultaty?

10. duckdb

W notatniku można łączyć sie z bazą danych za pomocą sql magic (obecnie jupysql). Konieczny jest pakiet SQLAlchemy i odpowiedni driver bazy danych. W obrazie Dockera jest on zainstalowany.

%load_ext sql
%sql duckdb:///wikipedia.duckdb
%config SqlMagic.named_parameters = "enabled"

Następnie można wykonywać kwerendy inline

%sql select * from vocabulary limit 10

lub wieloliniowe

%%sql
-- Sprawdzamy w ilu dokumentach pojawia się "piłkarz" vs "pedagog"
SELECT 
    v.word, 
    count(t.title) as doc_count
FROM (
    SELECT title, unnest(vector_sparse) as val, generate_subscripts(vector_sparse, 1) - 1 as idx 
    FROM wikipedia_corpus
) t
JOIN vocabulary v ON t.idx = v.word_index
WHERE v.word IN ('piłkarz', 'pedagog', 'lekkoatleta', 'wykładowca') AND t.val > 0
GROUP BY v.word;

Przykład wyszukiwania wektorowego

if 'nlp' not in globals():
    import spacy
    nlp = spacy.load("pl_core_news_lg")
 
query_text = """
Tensor – obiekt matematyczny, będący – w pewien szczególny sposób określonym – uogólnieniem pojęcia wektora[a][1]. 
Zbiór wszystkich tensorów wraz z odpowiednimi działaniami dodawania i mnożenia przez skalar, 
nazywa się przestrzenią tensorową. Tensory, podobnie jak wektory, mogą być swobodne i zaczepione. 
Rozważa się pola tensorowe (nazywane również w skrócie tensorami), czyli pola, które każdemu punktowi 
przestrzeni przypisują pewien tensor. Tensory, które zmieniają się przy zmianie skali, ściśle nazywa się 
gęstościami tensorowymi.
"""
 
doc = nlp(query_text)
query_vector = doc.vector.tolist()

Wywołanie SQL

%%sql top_documents <<
SELECT 
    title, 
    CASE 
        WHEN LENGTH(text) > 200 THEN LEFT(text, 200) || '...'
        ELSE text
    END AS text_truncated,
    -- Operator <=> oblicza odległość cosinusową (im mniejsza, tym bardziej podobne dokumenty)
    (vector <=> :query_vector) AS cosine_distance
FROM 
    wikipedia_corpus
ORDER BY 
    cosine_distance ASC
LIMIT 10;

Wynik (top_documents typu ResultSet)

title text_truncated cosine_distance
0 Empedokles Empedokles z Akragas (gr. Empedokles ho Akragantinos, ur. ok. 494, zm. ok. 434 p.n.e.) – starogrecki uczony: uzdrowiciel, filozof, poeta i polityk. Twórca koncepcji czterech żywiołów. Życiorys Pochodz… 0.15937
1 Personifikacja Personifikacja (z łac. persona – osoba i facere – robić) lub uosobienie – figura retoryczna i środek stylistyczny polegające na metaforycznym przedstawianiu zwierząt i roślin, przedmiotów nieożywionyc… 0.166853
2 Maska Maska – przykrycie twarzy lub jej części, z otworami na oczy, początkowo używane w celach magicznych lub obrzędowych. Maska jest przedmiotem noszonym zazwyczaj na twarzy, zwyczajowo dla ochrony, rozry… 0.173473
3 Magia Magia, czary – ogół wierzeń i praktyk opartych na przekonaniu o istnieniu sił nadprzyrodzonych, które można opanować za pomocą odpowiednich zaklęć i określonych czynności. Osoba zajmująca się magią (m… 0.176259
4 Metafora Metafora (gr. metaphorá), inaczej przenośnia – językowy środek stylistyczny, w którym obce znaczeniowo wyrazy są ze sobą składniowo zestawione, tworząc związek frazeologiczny o innym znaczeniu niż dos… 0.17997
5 Mem Mem (od gr. mimesis „naśladownictwo”) – w memetyce najmniejsza jednostka informacji kulturowej (informacji przekazywanej pozagenetycznie), analogiczna do genu, będącego jednostką ewolucji biologicznej… 0.182623
6 Wyobraźnia Wyobraźnia – zdolność do przywoływania i tworzenia w myślach wyobrażeń. Źródłem przechowywanych w pamięci wyobrażeń są zmysły. Część odpowiadająca za wyobraźnię jest umiejscowiona w prawej półkuli móz… 0.184398
7 Homonimia (językoznawstwo) Homonimia (gr. homós „jednakowy, ten sam, taki sam; podobny” i ónoma „imię; wyraz; tytuł”) – relacja wyrażania różnych znaczeń za pomocą identycznych form językowych. Występuje w morfologii fleksyjnej… 0.185075
8 Sansara Saṅsāra lub saṃsāra (pali, sans.: संसार, tel.: సంసారం; chiń.: trad. 輪迴, upr. 轮回, pinyin lún huí, jap.: 輪廻 rinne) – w hinduizmie, dźinizmie i buddyzmie termin dosłownie oznacza nieustanne wędrowanie, c… 0.185142
9 Habituacja Habituacja, przywykanie – jedna z form nieasocjacyjnego uczenia się; proces poznawczy, polegający na stopniowym zanikaniu reakcji na powtarzający się bodziec, jeżeli nie niesie on żadnych istotnych zm… 0.185145

Operator ⇔ zwraca $$\text{cosine_distance} = 1 - \text{cosine_similarity}$$

Przykład wyszukiwania pełnotekstowego

W wyszukiwaniu pełnotekstowym (ang. Full Text Search (FTS)) korzysta się z indeksu token → dokument. Mimo jego braku w naszej bazie danych dla 10_000 dokumentów można je całkiem efektywnie wykonać…

Rozszerzenie FTS do duckdb istnieje. Można je załadować i zbudować indeks https://duckdb.org/docs/current/core_extensions/full_text_search

query_words=[t.lemma_.lower() for t in doc if not t.is_stop and t.is_alpha]

w osobnej komórce

%%sql top_documents_fts <<
SELECT 
    title, 
    CASE 
        WHEN LENGTH(text) > 200 THEN LEFT(text, 200) || '...'
        ELSE text
    END AS text_truncated,
    len(
        list_intersect(tokens, :query_words)
    ) AS token_matches
FROM 
    wikipedia_corpus
WHERE 
    token_matches > 0
ORDER BY 
    token_matches DESC
OFFSET 1
LIMIT 10;
title text_truncated token_matches
0 Wektor Wektor – obiekt matematyczny opisywany za pomocą wielkości: modułu (nazywanego też – zdaniem niektórych niepoprawnie – długością lub wartością), kierunku wraz ze zwrotem (określającym orientację wzdłu… 27
1 Liczby zespolone Liczby zespolone – liczby będące elementami rozszerzenia ciała liczb rzeczywistych o jednostkę urojoną to znaczy pierwiastek wielomianu Liczby zespolone rozszerzają koncepcję jednowymiarowej osi liczb… 24
2 Gwiazda Gwiazda – kuliste ciało niebieskie, stanowiące skupisko powiązanej grawitacyjnie materii. Przynajmniej przez część swojego istnienia emituje w sposób stabilny promieniowanie elektromagnetyczne (w szcz… 24
3 Historia nauki Historia nauki – dziedzina wiedzy opisująca tworzenie się i rozwój wyspecjalizowanych nauk szczegółowych badających przebieg procesów przyrodniczych i społecznych. Jest to stosunkowo młoda dyscyplina … 23
4 Przestrzeń liniowa Przestrzeń liniowa, przestrzeń wektorowa – rodzaj struktury algebraicznej złożonej z dwóch zbiorów oraz dwóch działań: wewnętrznego i zewnętrznego. Elementy tych zbiorów są nazywane wektorami i skalar… 23
5 Funkcja Funkcja ( „odbywanie, wykonywanie, czynność”) – dla danych dwóch zbiorów i przyporządkowanie każdemu elementowi zbioru dokładnie jednego elementu zbioru . Oznacza się ją na ogół itd. Jeśli funkcja prz… 23
6 Wolna wola Wolna wola – zdolność podmiotów do dokonywania wyborów bez ograniczeń ze strony różnych czynników. Spośród czynników o historycznym znaczeniu dla kształtowania się idei należy wymienić ograniczenia me… 22
7 Pochodna funkcji Pochodna funkcji – nieformalnie: miara szybkości funkcji, czyli tempa zmian jej wartości względem zmian jej argumentów. Dokładna definicja pochodnej zależy od kontekstu, ponieważ pojęcie to stosuje si… 22
8 Okręt podwodny Okręt podwodny – wojskowa jednostka pływająca, okręt konstrukcyjnie przystosowany do prowadzenia działań i operacji zarówno na powierzchni, jak i pod wodą; współcześnie jedna z głównych klas okrętów. … 22
9 Szczególna teoria względności Szczególna teoria względności (STW) – teoria fizyczna stworzona przez Alberta Einsteina w 1905 roku. Zmieniła ona sposób pojmowania czasu, przestrzeni i ruchu opisanych wcześniej w newtonowskiej mecha… 22

8-) Czasem FTS jest bardziej skuteczny i dlatego stosuje się metody hybrydowe. Jak widać, w rzypadku dokumentów wybranych na podstawie tokenów odległość cosinusowa jest znacznie większa.

%%sql top_documents_fts <<
SELECT 
    title, 
    CASE 
        WHEN LENGTH(text) > 200 THEN LEFT(text, 200) || '...'
        ELSE text
    END AS text_truncated,
    len(
        list_intersect(tokens, :query_words)
    ) AS token_matches,
    (vector <=> :query_vector) AS cosine_distance
FROM 
    wikipedia_corpus
WHERE 
    token_matches > 0
ORDER BY 
    token_matches DESC,
    cosine_distance ASC
OFFSET 1
LIMIT 10;
title text_truncated token_matches cosine_distance
0 Wektor Wektor – obiekt matematyczny opisywany za pomocą wielkości: modułu (nazywanego też – zdaniem niektórych niepoprawnie – długością lub wartością), kierunku wraz ze zwrotem (określającym orientację wzdłu… 27 0.267287
1 Gwiazda Gwiazda – kuliste ciało niebieskie, stanowiące skupisko powiązanej grawitacyjnie materii. Przynajmniej przez część swojego istnienia emituje w sposób stabilny promieniowanie elektromagnetyczne (w szcz… 24 0.215843
2 Liczby zespolone Liczby zespolone – liczby będące elementami rozszerzenia ciała liczb rzeczywistych o jednostkę urojoną to znaczy pierwiastek wielomianu Liczby zespolone rozszerzają koncepcję jednowymiarowej osi liczb… 24 0.23018
3 Przestrzeń liniowa Przestrzeń liniowa, przestrzeń wektorowa – rodzaj struktury algebraicznej złożonej z dwóch zbiorów oraz dwóch działań: wewnętrznego i zewnętrznego. Elementy tych zbiorów są nazywane wektorami i skalar… 23 0.235495
4 Historia nauki Historia nauki – dziedzina wiedzy opisująca tworzenie się i rozwój wyspecjalizowanych nauk szczegółowych badających przebieg procesów przyrodniczych i społecznych. Jest to stosunkowo młoda dyscyplina … 23 0.253698
5 Funkcja Funkcja ( „odbywanie, wykonywanie, czynność”) – dla danych dwóch zbiorów i przyporządkowanie każdemu elementowi zbioru dokładnie jednego elementu zbioru . Oznacza się ją na ogół itd. Jeśli funkcja prz… 23 0.280777
6 Wolna wola Wolna wola – zdolność podmiotów do dokonywania wyborów bez ograniczeń ze strony różnych czynników. Spośród czynników o historycznym znaczeniu dla kształtowania się idei należy wymienić ograniczenia me… 22 0.197654
7 Szczególna teoria względności Szczególna teoria względności (STW) – teoria fizyczna stworzona przez Alberta Einsteina w 1905 roku. Zmieniła ona sposób pojmowania czasu, przestrzeni i ruchu opisanych wcześniej w newtonowskiej mecha… 22 0.204952
8 Pochodna funkcji Pochodna funkcji – nieformalnie: miara szybkości funkcji, czyli tempa zmian jej wartości względem zmian jej argumentów. Dokładna definicja pochodnej zależy od kontekstu, ponieważ pojęcie to stosuje si… 22 0.243152
9 Okręt podwodny Okręt podwodny – wojskowa jednostka pływająca, okręt konstrukcyjnie przystosowany do prowadzenia działań i operacji zarówno na powierzchni, jak i pod wodą; współcześnie jedna z głównych klas okrętów. … 22 0.290808

Aby zamknąć połaczenie

%sql --close duckdb:///wikipedia.duckdb

10.1 Interpolacja liniowa w przestrzeni wielowymiarowej

TODO Napisz (lub wygeneruj za pomocą LLMa) następującą kwerendę.

  • W zmiennych title_a i title_b zdefiniowano tytuły dokumentów. Tytuły są unikalne w tabeli wikipedia_corpus
  • W zmiennej feature_column podano nazwę kolumny z cechami. Ma postać wyrażenia regularnego, np. '^vector$'

Kwerenda znajduje w bazie wektory z kolumny feature_column . Następnie tworzy 8 punktów pośrednich na linii prostej łączącej cechy pierwszego i ostatniego puntu. Dla każdego z tych punktów znajduje dokładnie 1 najbliżej położony dokument. Ostatecznie zwraca listę tytułów znalezionych kolejno dokumentów i około 100 pierwszych znaków tych dokumentów. Lista ma zawierać początkowy i końcowy dokument.

Zaimplementu kwerendę jako %%sql (sql magic/jupysql)

Aby przekazać nazwę kolumny możesz skorzystać z poniższego przykładu:

%sql select columns(:features_column) from wikipedia_corpus where title=:title_a

Jeżeli generujesz kod za pomocą LLM - napisz jakim i w ilu iteracjach otrzymałeś ostateczny wynik 8-O

TODO Przetestuj dwa przypadki:

  • punkty końcowe wewnątrz wybranego klastra
  • punkty końcowe skrajnie oddalone

Dla nich porównaj trajektorie na cechach o wymiarach 300, 10 i 2. Czym się różnią i dlaczego?

Przykładowy wynik (trajektoria na umap_2_dense):

krok tytul fragment_tekstu
0 0 Windows Me Windows Me (Millennium Edition, Windows ME, nazwa robocza Millennium) – hybrydowy 16/32-bitowy syste…
1 1 Dystrybucja Linuksa Dystrybucja Linuksa – uniksopodobny kompletny system operacyjny zbudowany na bazie jądra Linux. Znak…
2 2 PCD PCD – standard zapisu fotografii jako danych cyfrowych, rozwijany przez Kodak. PCD – dysk z warstwam…
3 3 Paliwo umowne Paliwo umowne - hipotetyczne paliwo mające w przypadku węgla kamiennego wartość opałową około 29300 …
4 4 Fitoplankton Fitoplankton – mikroskopijne organizmy roślinne (w tym glony niezaliczane do królestwa roślin w niek…
5 5 Czujnik indukcyjny Czujnik indukcyjny – element automatyki przemysłowej, którego działanie oparte jest na zmianie param…
6 6 Izotera Izotera (izo- + = „lato”) – linia na mapie łącząca punkty o jednakowej średniej temperaturze powietr…
7 7 Przekładnia hydrostatyczna Przekładnia hydrostatyczna - przekładnia składająca się z jednej lub więcej par pomp wyporowych i si…
8 8 Przekształcenia fonetyczne w języku japońskim Przekształcenia obligatoryjne W językach naturalnych sklejanie morfemów nie jest prostą operacją. Sł…
9 9 Język rosyjski Język rosyjski (ros. , russkij jazyk; dawniej też: język wielkoruski) – język z grupy wschodniosłowi…
ed/lab_clustering.txt · Last modified: 2026/05/25 03:50 by pszwed
CC Attribution-Share Alike 4.0 International
Driven by DokuWiki Recent changes RSS feed Valid CSS Valid XHTML 1.0