Spaces:
Running
Running
Download computation_clusters.py from Enayut/topic_modelling_agent: direct link, hf CLI and curl.
- Browser
- Download file 9.73 kB
-
https://huggingface.co/spaces/Enayut/topic_modelling_agent/resolve/main/computation_clusters.py
- Command line
-
hf download hf://spaces/Enayut/topic_modelling_agent/computation_clusters.py
-
curl -L -o computation_clusters.py https://huggingface.co/spaces/Enayut/topic_modelling_agent/resolve/main/computation_clusters.py
9.73 kB
| """ | |
| computation_clusters.py β SPECTER2 semantic clustering for computation.csv | |
| Self-contained module that: | |
| 1. Loads computation.csv | |
| 2. Combines text fields β single string per paper | |
| 3. Generates SPECTER2 embeddings (cached as .npy) | |
| 4. UMAP 2D reduction | |
| 5. HDBSCAN clustering | |
| 6. Auto-labels clusters via TF-IDF keywords | |
| 7. Returns everything needed for the Streamlit Computational Strategy Map tab | |
| """ | |
| import os | |
| import hashlib | |
| import logging | |
| from typing import Dict, List, Tuple, Any, Optional | |
| import numpy as np | |
| import pandas as pd | |
| logger = logging.getLogger(__name__) | |
| # Cache directory for embeddings | |
| _CACHE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".comp_cache") | |
| os.makedirs(_CACHE_DIR, exist_ok=True) | |
| def _cache_key(texts: List[str]) -> str: | |
| """Stable hash of input texts for caching.""" | |
| h = hashlib.sha256() | |
| for t in texts: | |
| h.update(t.encode("utf-8", errors="replace")) | |
| return h.hexdigest()[:16] | |
| def _generate_specter2_embeddings(texts: List[str], batch_size: int = 8) -> np.ndarray: | |
| """ | |
| Generate SPECTER2 embeddings via HuggingFace adapters, with .npy caching. | |
| """ | |
| key = _cache_key(texts) | |
| cache_path = os.path.join(_CACHE_DIR, f"comp_emb_{key}.npy") | |
| if os.path.exists(cache_path): | |
| logger.info("β Loaded cached embeddings from %s", cache_path) | |
| return np.load(cache_path) | |
| import torch | |
| from transformers import AutoTokenizer | |
| from adapters import AutoAdapterModel | |
| logger.info("Loading SPECTER2 model β¦") | |
| tokenizer = AutoTokenizer.from_pretrained("allenai/specter2_base") | |
| model = AutoAdapterModel.from_pretrained("allenai/specter2_base") | |
| model.load_adapter("allenai/specter2", source="hf", load_as="proximity", set_active=True) | |
| device = "cuda" if torch.cuda.is_available() else "cpu" | |
| model.to(device) | |
| model.eval() | |
| embeddings = [] | |
| total_batches = (len(texts) + batch_size - 1) // batch_size | |
| logger.info("Generating embeddings for %d texts (%d batches) on %s β¦", len(texts), total_batches, device) | |
| for i in range(0, len(texts), batch_size): | |
| batch = texts[i : i + batch_size] | |
| inputs = tokenizer( | |
| batch, padding=True, truncation=True, | |
| return_tensors="pt", return_token_type_ids=False, max_length=512, | |
| ) | |
| inputs = {k: v.to(device) for k, v in inputs.items()} | |
| with torch.no_grad(): | |
| output = model(**inputs) | |
| batch_emb = output.last_hidden_state[:, 0, :].cpu().numpy() | |
| embeddings.append(batch_emb) | |
| result = np.vstack(embeddings) | |
| np.save(cache_path, result) | |
| logger.info("β Embeddings cached β %s", cache_path) | |
| return result | |
| def _umap_reduce(embeddings: np.ndarray, n_components: int = 2) -> np.ndarray: | |
| """UMAP dimensionality reduction.""" | |
| from umap import UMAP | |
| n_neighbors = min(15, len(embeddings) - 1) | |
| reducer = UMAP( | |
| n_neighbors=n_neighbors, | |
| n_components=n_components, | |
| min_dist=0.1, | |
| metric="cosine", | |
| random_state=42, | |
| ) | |
| return reducer.fit_transform(embeddings) | |
| def _hdbscan_cluster(embeddings: np.ndarray) -> np.ndarray: | |
| """ | |
| HDBSCAN clustering via UMAP 5D intermediate reduction. | |
| Tries multiple parameter combos to find the best clustering. | |
| Falls back gracefully for small datasets. | |
| """ | |
| from umap import UMAP | |
| from hdbscan import HDBSCAN | |
| n_samples = len(embeddings) | |
| # For very small datasets, use 3D intermediate | |
| n_intermediate = min(5, n_samples - 2) | |
| if n_intermediate < 2: | |
| n_intermediate = 2 | |
| n_neighbors = min(15, n_samples - 1) | |
| umap_inter = UMAP( | |
| n_neighbors=n_neighbors, | |
| n_components=n_intermediate, | |
| min_dist=0.0, | |
| metric="cosine", | |
| random_state=42, | |
| ) | |
| reduced = umap_inter.fit_transform(embeddings) | |
| best_labels = None | |
| best_n = 0 | |
| # Try parameter sweep | |
| for mcs in [3, 4, 5, 7]: | |
| for ms in [2, 3]: | |
| if mcs >= n_samples: | |
| continue | |
| clusterer = HDBSCAN( | |
| min_cluster_size=mcs, | |
| min_samples=min(ms, mcs - 1) if mcs > 1 else 1, | |
| metric="euclidean", | |
| cluster_selection_method="eom", | |
| ) | |
| labels = clusterer.fit_predict(reduced) | |
| n_clusters = len(set(labels)) - (1 if -1 in labels else 0) | |
| if n_clusters > best_n: | |
| best_labels = labels | |
| best_n = n_clusters | |
| if best_labels is None or best_n < 2: | |
| logger.warning("HDBSCAN produced < 2 clusters, using single cluster") | |
| best_labels = np.zeros(n_samples, dtype=int) | |
| # Reassign noise (-1) to nearest cluster centroid | |
| noise_mask = best_labels == -1 | |
| if noise_mask.any(): | |
| from sklearn.metrics.pairwise import cosine_similarity | |
| valid_clusters = sorted(set(best_labels) - {-1}) | |
| if valid_clusters: | |
| centroids = np.array([embeddings[best_labels == c].mean(axis=0) for c in valid_clusters]) | |
| sims = cosine_similarity(embeddings[noise_mask], centroids) | |
| nearest = sims.argmax(axis=1) | |
| best_labels[noise_mask] = [valid_clusters[n] for n in nearest] | |
| logger.info("HDBSCAN: %d clusters from %d papers", len(set(best_labels)), n_samples) | |
| return best_labels | |
| def _label_clusters(texts: List[str], labels: np.ndarray) -> Dict[int, str]: | |
| """Generate cluster labels using TF-IDF top keywords.""" | |
| from sklearn.feature_extraction.text import TfidfVectorizer | |
| cluster_ids = sorted(set(labels)) | |
| # Merge all texts per cluster | |
| cluster_texts = {} | |
| for cid in cluster_ids: | |
| mask = labels == cid | |
| cluster_texts[cid] = " ".join([texts[i] for i in range(len(texts)) if mask[i]]) | |
| all_docs = [cluster_texts[c] for c in cluster_ids] | |
| vectorizer = TfidfVectorizer( | |
| stop_words="english", max_df=0.95, min_df=1, | |
| ngram_range=(1, 2), max_features=3000, | |
| ) | |
| tfidf = vectorizer.fit_transform(all_docs) | |
| features = vectorizer.get_feature_names_out() | |
| result = {} | |
| for i, cid in enumerate(cluster_ids): | |
| scores = tfidf[i].toarray().flatten() | |
| top_idx = scores.argsort()[-4:][::-1] | |
| keywords = [features[j] for j in top_idx if scores[j] > 0] | |
| label = " / ".join(w.title() for w in keywords[:3]) if keywords else f"Cluster {cid}" | |
| result[cid] = label | |
| return result | |
| def _build_cluster_keywords(texts: List[str], labels: np.ndarray) -> Dict[int, List[str]]: | |
| """Get top-5 keywords per cluster for the stats panel.""" | |
| from sklearn.feature_extraction.text import TfidfVectorizer | |
| cluster_ids = sorted(set(labels)) | |
| cluster_texts = {} | |
| for cid in cluster_ids: | |
| mask = labels == cid | |
| cluster_texts[cid] = " ".join([texts[i] for i in range(len(texts)) if mask[i]]) | |
| all_docs = [cluster_texts[c] for c in cluster_ids] | |
| vectorizer = TfidfVectorizer( | |
| stop_words="english", max_df=0.95, min_df=1, | |
| ngram_range=(1, 2), max_features=3000, | |
| ) | |
| tfidf = vectorizer.fit_transform(all_docs) | |
| features = vectorizer.get_feature_names_out() | |
| result = {} | |
| for i, cid in enumerate(cluster_ids): | |
| scores = tfidf[i].toarray().flatten() | |
| top_idx = scores.argsort()[-5:][::-1] | |
| result[cid] = [features[j] for j in top_idx if scores[j] > 0] | |
| return result | |
| def run_computation_clustering(csv_path: str = "computation.csv") -> Dict[str, Any]: | |
| """ | |
| Full pipeline: Load CSV β Embed β Reduce β Cluster β Label. | |
| Returns a dict with all data needed by the Streamlit tab: | |
| - df: DataFrame with original columns + cluster_id, cluster_name, x, y | |
| - cluster_names: {cluster_id: label} | |
| - cluster_keywords: {cluster_id: [kw1, kw2, ...]} | |
| - strategy_types: list of unique strategy types | |
| - n_clusters: int | |
| - n_papers: int | |
| """ | |
| logger.info("=" * 50) | |
| logger.info("Computation Strategy Clustering Pipeline") | |
| logger.info("=" * 50) | |
| # 1. Load CSV | |
| df = pd.read_csv(csv_path, encoding="utf-8", on_bad_lines="skip") | |
| df.columns = [c.strip() for c in df.columns] | |
| for col in df.columns: | |
| df[col] = df[col].fillna("") | |
| logger.info("Loaded %d papers from %s", len(df), csv_path) | |
| # 2. Combine text fields | |
| combined = [] | |
| for _, row in df.iterrows(): | |
| parts = [ | |
| str(row.get("Paper Title", "")), | |
| str(row.get("Computational Strategies Used", "")), | |
| str(row.get("Strategy Type", "")), | |
| str(row.get("Supporting Keywords / Evidence", "")), | |
| ] | |
| combined.append(" ".join(p for p in parts if p.strip())) | |
| # 3. SPECTER2 embeddings (cached) | |
| embeddings = _generate_specter2_embeddings(combined) | |
| # 4. UMAP 2D | |
| coords_2d = _umap_reduce(embeddings) | |
| # 5. HDBSCAN | |
| cluster_labels = _hdbscan_cluster(embeddings) | |
| # 6. TF-IDF labeling | |
| cluster_names = _label_clusters(combined, cluster_labels) | |
| cluster_keywords = _build_cluster_keywords(combined, cluster_labels) | |
| # 7. Attach to DataFrame | |
| df["cluster_id"] = cluster_labels.tolist() | |
| df["cluster_name"] = df["cluster_id"].map(cluster_names) | |
| df["x"] = coords_2d[:, 0].tolist() | |
| df["y"] = coords_2d[:, 1].tolist() | |
| strategy_types = sorted(set(s.strip() for s in df["Strategy Type"].unique() if s.strip())) | |
| logger.info("Pipeline complete: %d papers β %d clusters", len(df), len(cluster_names)) | |
| return { | |
| "df": df, | |
| "cluster_names": cluster_names, | |
| "cluster_keywords": cluster_keywords, | |
| "strategy_types": strategy_types, | |
| "n_clusters": len(cluster_names), | |
| "n_papers": len(df), | |
| } | |