Table of Contents
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:
- Pobrane zostaną teksty 10000 artykułów z Wikipedii
- Zbudowane zostaną ich reprezentacje wektorowe (wektory osadzeń i wektory TF-IDF)
- Zastosujemy UMAP do redukcji wymiarów wektorów na potrzeby klasteryzacji i wizualizacji
- Do dokumentów zostaną dodane etykiety klasteryzacji dla różnych algorytmów
- 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 notatnikwikipedia.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=-1powoduje 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 |
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
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… |










