====== 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'' * [[https://duckdb.org/|duckdb]] - do zapisu danych i wykonania kwerend SQL * [[https://spacy.io/|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 {{ :ed:clustering-jupyter.zip |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 [[https://spacy.io/|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 [[https://spacy.io/usage/processing-pipelines| 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. 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 [[https://en.wikipedia.org/wiki/Bag-of-words_model| 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() {{ :ed:umap_na_spacy_dense.png?direct&400 |}} ==== 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. {{ :ed:kmeans_10_umap20dense.png?direct&600 |}} ==== 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. 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() {{ :ed:kmeans-woronoj.png?direct&600 |}} ==== 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: 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}") {{ :ed:word-cloud.png?direct&600 |}} **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. 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: 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: {{ :ed: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. 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 {{ :ed:gmm_sparse.png?direct&600 |}} {{ :ed:gmm_dense.png?direct&600 |}} ==== 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) {{ :ed:k-dist-plot.png?direct&600 |}} 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"
Liczba klastrów: {n_clusters} | Szum: {noise_pct:.1f}%", 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. {{ :ed:dbscan.png?direct&600 |}} ==== 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()'' {{ :ed:hdbscan.png?direct&600 |}} **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. 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. 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: {{ :ed:v-measure-similarity.png?direct&600 |}} **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... |