CoolFace
Apppublic

BHAVIKBANKER/BERTopic_AG_final

sourceHugging Faceupdated 4mo agoView on Hugging Face
1likes
tools.py490 linesDownload Raw Back to root
1"""2tools.py3--------4Topic-modelling pipeline: SPECTER-2 → UMAP → HDBSCAN5with multi-objective Bayesian optimisation over UMAP + HDBSCAN6parameters (§3.1–§3.6 of the methodology guide).7 8No BERTopic wrapper — bare UMAP + HDBSCAN on SPECTER-2 embeddings.9"""10 11import re12import logging13import pandas as pd14import numpy as np15from typing import Optional16from collections import Counter, defaultdict17 18from sentence_transformers import SentenceTransformer19from umap import UMAP20from hdbscan import HDBSCAN21from sklearn.metrics import adjusted_rand_score22from sklearn.metrics.pairwise import cosine_similarity23import optuna24 25# ---------------------------------------------------------------------------26# Logging27# ---------------------------------------------------------------------------28logging.basicConfig(level=logging.INFO, format="%(levelname)s | %(message)s")29logger = logging.getLogger(__name__)30optuna.logging.set_verbosity(optuna.logging.WARNING)31 32 33# ---------------------------------------------------------------------------34# Data Loading (unchanged)35# ---------------------------------------------------------------------------36def load_csv(filepath: str) -> pd.DataFrame:37    df = pd.read_csv(filepath)38    df.columns = df.columns.str.lower()39    required = {"title", "abstract"}40    missing = required - set(df.columns)41    if missing:42        raise ValueError(f"CSV missing column(s): {missing}")43    logger.info("Loaded %d rows from '%s'.", len(df), filepath)44    return df45 46 47# ---------------------------------------------------------------------------48# §3.1 — Input unit: title + abstract concatenation49# ---------------------------------------------------------------------------50def prepare_documents(df: pd.DataFrame) -> list[str]:51    """One string per paper: title + abstract (§3.1 input unit)."""52    docs = (df["title"].fillna("") + ". " + df["abstract"].fillna("")).tolist()53    logger.info("Prepared %d title+abstract documents.", len(docs))54    return docs55 56 57# ---------------------------------------------------------------------------58# §3.1 — Embed with SPECTER-2 (cached model for speed)59# ---------------------------------------------------------------------------60_MODEL_CACHE = {}61 62def embed_documents(63    docs: list[str],64    model_name: str = "allenai/specter2_base",65) -> np.ndarray:66    """Embed with SPECTER-2. Deterministic — no tuning (§3.3)."""67    if model_name not in _MODEL_CACHE:68        logger.info("Loading %s (first time, will be cached)…", model_name)69        _MODEL_CACHE[model_name] = SentenceTransformer(model_name)70    model = _MODEL_CACHE[model_name]71    embeddings = model.encode(docs, show_progress_bar=True, batch_size=64)72    logger.info("Embedded %d docs → %s", len(docs), embeddings.shape)73    return embeddings74 75 76# ---------------------------------------------------------------------------77# §3.2 — Cluster discipline checks78# ---------------------------------------------------------------------------79def check_discipline(labels: np.ndarray, n_docs: int) -> dict:80    """Two hard constraints: max-mass ≤ 25 %, min-size ≥ 5."""81    counts = Counter(int(l) for l in labels)82    unique = [l for l in counts if l != -1]83 84    if not unique:85        return dict(max_mass_pct=0, max_mass_ok=False,86                    min_size=0, min_size_ok=False,87                    n_clusters=0, n_noise=counts.get(-1, 0))88 89    max_mass_pct = max(counts[l] / n_docs for l in unique)90    min_size     = min(counts[l] for l in unique)91 92    return dict(93        max_mass_pct=round(max_mass_pct, 4),94        max_mass_ok=max_mass_pct <= 0.25,95        min_size=int(min_size),96        min_size_ok=min_size >= 5,97        n_clusters=len(unique),98        n_noise=counts.get(-1, 0),99        cluster_sizes={l: counts[l] for l in sorted(unique)},100    )101 102 103# ---------------------------------------------------------------------------104# §3.4 — Quality metrics105# ---------------------------------------------------------------------------106def compute_persistence(clusterer: HDBSCAN) -> float:107    """Average cluster persistence from the condensed tree."""108    try:109        p = getattr(clusterer, "cluster_persistence_", None)110        if p is not None and len(p) > 0:111            return float(np.mean(p))112    except Exception:113        pass114    return 0.0115 116 117def per_cluster_persistence(clusterer: HDBSCAN, labels: np.ndarray) -> dict:118    """Map each cluster ID to its persistence score (§8)."""119    try:120        p = getattr(clusterer, "cluster_persistence_", None)121        if p is None or len(p) == 0:122            return {}123        unique = sorted(set(int(l) for l in labels if l != -1))124        return {cid: float(p[i]) if i < len(p) else 0.0125                for i, cid in enumerate(unique)}126    except Exception:127        return {}128 129 130def compute_dbcv(reduced: np.ndarray, labels: np.ndarray) -> float:131    """Density-Based Cluster Validity index."""132    try:133        from hdbscan.validity import validity_index134        ul = set(labels); ul.discard(-1)135        if len(ul) < 2:136            return -1.0137        return float(validity_index(reduced.astype(np.float64), labels))138    except Exception as e:139        logger.warning("DBCV failed: %s", e)140        return -1.0141 142 143def compute_stability(embeddings: np.ndarray, params: dict,144                      n_seeds: int = 3) -> float:145    """Cluster-recurrence stability via pairwise ARI across seeds (§3.4).146    Uses 3 seeds by default for speed (spec allows 3–5)."""147    all_labels = []148    for s in range(n_seeds):149        u = UMAP(n_neighbors=params["n_neighbors"],150                 n_components=params["n_components"],151                 min_dist=0.0, metric="cosine",152                 random_state=s * 7 + 1, low_memory=True)153        red = u.fit_transform(embeddings)154        h = HDBSCAN(min_cluster_size=params["min_cluster_size"],155                    min_samples=params["min_samples"],156                    metric="euclidean",157                    cluster_selection_method=params["csm"],158                    cluster_selection_epsilon=params["cse"])159        all_labels.append(h.fit_predict(red))160 161    aris = []162    for i in range(len(all_labels)):163        for j in range(i + 1, len(all_labels)):164            aris.append(adjusted_rand_score(all_labels[i], all_labels[j]))165    return float(np.mean(aris)) if aris else 0.0166 167 168# ---------------------------------------------------------------------------169# §3.4 — Bayesian optimisation objective170# ---------------------------------------------------------------------------171def _objective(trial, embeddings, n_docs):172    """Single Optuna trial — returns (persistence, dbcv, stability_placeholder)."""173    n_neighbors = trial.suggest_categorical("n_neighbors", [5, 10, 15, 30, 50])174    n_components = trial.suggest_int("n_components", 5, 10)175    mcs = trial.suggest_int(176        "min_cluster_size",177        max(5, int(0.01 * n_docs)),178        max(20, int(0.05 * n_docs)),179    )180    ms = trial.suggest_int("min_samples", 1, mcs)181    csm = trial.suggest_categorical("csm", ["eom", "leaf"])182    cse = trial.suggest_float("cse", 0.0, 0.3, step=0.05)183 184    params = dict(n_neighbors=n_neighbors, n_components=n_components,185                  min_cluster_size=mcs, min_samples=ms, csm=csm, cse=cse)186 187    u = UMAP(n_neighbors=n_neighbors, n_components=n_components,188             min_dist=0.0, metric="cosine", random_state=42,189             low_memory=True)190    red = u.fit_transform(embeddings)191 192    h = HDBSCAN(min_cluster_size=mcs, min_samples=ms, metric="euclidean",193                cluster_selection_method=csm,194                cluster_selection_epsilon=cse,195                allow_single_cluster=False, gen_min_span_tree=True)196    labels = h.fit_predict(red)197 198    disc = check_discipline(labels, n_docs)199    trial.set_user_attr("params", params)200    trial.set_user_attr("discipline", disc)201    trial.set_user_attr("labels", labels.tolist())202 203    # Hard-constraint violation → worst scores204    if not disc["max_mass_ok"] or not disc["min_size_ok"]:205        trial.set_user_attr("pass", False)206        return 0.0, -1.0, 0.0207 208    trial.set_user_attr("pass", True)209    pers = compute_persistence(h)210    dbcv = compute_dbcv(red, labels)211    trial.set_user_attr("persistence", pers)212    trial.set_user_attr("dbcv", dbcv)213    return pers, dbcv, 0.5          # stability computed only for winner214 215 216# ---------------------------------------------------------------------------217# §3.4 — Run the full Bayesian loop218# ---------------------------------------------------------------------------219def run_bayesian_optimisation(220    embeddings: np.ndarray,221    n_trials: int = 50,222    progress_callback=None,223) -> dict:224    n_docs = len(embeddings)225    study = optuna.create_study(226        directions=["maximize", "maximize", "maximize"],227        sampler=optuna.samplers.TPESampler(seed=42, multivariate=True),228        study_name="specter2_umap_hdbscan",229    )230    trial_log = []231 232    def _cb(study, trial):233        d = trial.user_attrs.get("discipline", {})234        entry = dict(235            trial=trial.number,236            params=trial.user_attrs.get("params", {}),237            discipline_pass=trial.user_attrs.get("pass", False),238            persistence=trial.user_attrs.get("persistence", 0.0),239            dbcv=trial.user_attrs.get("dbcv", -1.0),240            n_clusters=d.get("n_clusters", 0),241            max_mass_pct=d.get("max_mass_pct", 0.0),242            min_size=d.get("min_size", 0),243            n_noise=d.get("n_noise", 0),244            values=list(trial.values) if trial.values else [],245        )246        trial_log.append(entry)247        if progress_callback:248            progress_callback(trial.number + 1, n_trials, entry)249 250    for i in range(n_trials):251        study.optimize(252            lambda t: _objective(t, embeddings, n_docs),253            n_trials=1, callbacks=[_cb], show_progress_bar=False,254        )255        # §3.6 convergence: 3 consecutive passing within 5 % of best256        passing = [e for e in trial_log if e["discipline_pass"]]257        if len(passing) >= 3 and i >= 9:   # allow early stop after 10 trials258            best_p = max(e["persistence"] for e in passing)259            if best_p > 0:260                last3 = passing[-3:]261                if all(abs(e["persistence"] - best_p) / best_p < 0.05262                       for e in last3):263                    logger.info("Converged at trial %d.", i + 1)264                    break265 266    # Select best passing trial (max persistence, then DBCV)267    passing_trials = [t for t in study.trials268                      if t.user_attrs.get("pass", False)]269    if passing_trials:270        best = max(passing_trials, key=lambda t: (t.values[0], t.values[1]))271    else:272        logger.warning("No trial passed discipline — using last trial.")273        best = study.trials[-1]274 275    bp = best.user_attrs["params"]276    labels = np.array(best.user_attrs["labels"])277    stability = compute_stability(embeddings, bp, n_seeds=3)278 279    return dict(280        best_params=bp, best_labels=labels,281        best_trial=best.number,282        persistence=best.user_attrs.get("persistence", 0.0),283        dbcv=best.user_attrs.get("dbcv", -1.0),284        stability=stability,285        discipline=best.user_attrs.get("discipline", {}),286        trial_log=trial_log,287        n_trials_run=len(trial_log),288    )289 290 291# ---------------------------------------------------------------------------292# §3.1 — 2-D UMAP for visualisation293# ---------------------------------------------------------------------------294def compute_2d_umap(embeddings: np.ndarray, seed: int = 42) -> np.ndarray:295    return UMAP(n_neighbors=15, n_components=2, min_dist=0.1,296                metric="cosine", random_state=seed,297                low_memory=True).fit_transform(embeddings)298 299 300# ---------------------------------------------------------------------------301# §3.1 — TF-IDF keyphrase extraction per cluster (3–5 phrases)302#         Fast alternative to KeyBERT — no extra model download needed.303# ---------------------------------------------------------------------------304def extract_keyphrases(docs: list[str], labels: np.ndarray,305                       top_n: int = 5) -> dict:306    from sklearn.feature_extraction.text import TfidfVectorizer307    cluster_docs = defaultdict(list)308    for doc, lab in zip(docs, labels):309        if lab != -1:310            cluster_docs[int(lab)].append(doc)311    out = {}312    for cid, cdocs in cluster_docs.items():313        if len(cdocs) < 2:314            out[cid] = []315            continue316        try:317            tfidf = TfidfVectorizer(318                stop_words="english", max_features=200,319                ngram_range=(1, 3), max_df=0.9, min_df=1)320            X = tfidf.fit_transform(cdocs)321            terms = tfidf.get_feature_names_out()322            scores = X.sum(axis=0).A1323            top_idx = scores.argsort()[::-1][:top_n]324            out[cid] = [(terms[i], float(scores[i])) for i in top_idx]325        except Exception as e:326            logger.warning("Keyphrase extraction cluster %d: %s", cid, e)327            out[cid] = []328    return out329 330 331# ---------------------------------------------------------------------------332# §3.1 — Strong / weak member counts via HDBSCAN probabilities333# ---------------------------------------------------------------------------334def strong_weak_members(labels: np.ndarray,335                        probabilities: np.ndarray) -> dict:336    mem = defaultdict(lambda: {"strong": 0, "weak": 0})337    for lab, prob in zip(labels, probabilities):338        if lab == -1:339            continue340        cid = int(lab)341        if prob >= 0.5:342            mem[cid]["strong"] += 1343        else:344            mem[cid]["weak"] += 1345    return dict(mem)346 347 348# ---------------------------------------------------------------------------349# §3.2 — Outlier reduction: reassign noise to nearest cluster (≤ 25 %)350# ---------------------------------------------------------------------------351def outlier_reduction(labels: np.ndarray, reduced: np.ndarray,352                      n_docs: int) -> np.ndarray:353    labels = labels.copy()354    cap = int(0.25 * n_docs)355    cdocs = defaultdict(list)356    for i, l in enumerate(labels):357        if l != -1:358            cdocs[int(l)].append(i)359    if not cdocs:360        return labels361    cids = list(cdocs.keys())362    centroids = np.vstack([reduced[cdocs[c]].mean(axis=0) for c in cids])363    noise = [i for i, l in enumerate(labels) if l == -1]364    moved = 0365    for idx in noise:366        dists = np.linalg.norm(centroids - reduced[idx], axis=1)367        for best in np.argsort(dists):368            tgt = cids[best]369            if len(cdocs[tgt]) < cap:370                labels[idx] = tgt371                cdocs[tgt].append(idx)372                moved += 1373                break374    logger.info("Outlier reduction: %d / %d noise reassigned.", moved, len(noise))375    return labels376 377 378# ---------------------------------------------------------------------------379# Representative docs (top-3 by centroid proximity)380# ---------------------------------------------------------------------------381def get_representative_docs(labels, embeddings, docs, top_n=3):382    cdocs = defaultdict(list)383    for i, l in enumerate(labels):384        if l != -1:385            cdocs[int(l)].append(i)386    out = {}387    for cid, idxs in cdocs.items():388        ce = embeddings[idxs].mean(axis=0).reshape(1, -1)389        sims = cosine_similarity(ce, embeddings[idxs])[0]390        top = np.argsort(sims)[-top_n:][::-1]391        out[cid] = [docs[idxs[t]] for t in top]392    return out393 394 395# ---------------------------------------------------------------------------396# §9 — RQ2 / RQ3 mismatch table397# ---------------------------------------------------------------------------398def build_mismatch_table(keyphrases: dict, cluster_labels: dict) -> list:399    """Compare cluster keyphrases against assigned labels to flag mismatches.400    Returns rows for a mismatch table (§9)."""401    rows = []402    for cid in sorted(keyphrases.keys()):403        kps = keyphrases.get(cid, [])404        kp_terms = [k[0] if isinstance(k, tuple) else k for k in kps[:5]]405        label = cluster_labels.get(cid, "")406        # Check overlap between label words and keyphrase terms407        label_words = set(label.lower().split())408        kp_words = set(" ".join(kp_terms).lower().split())409        overlap = label_words & kp_words410        noise = {"the","and","for","with","using","based","from","in","of","a","to"}411        overlap -= noise412        match_pct = len(overlap) / max(len(label_words - noise), 1)413        status = "MATCH" if match_pct >= 0.3 else "MISMATCH"414        rows.append(dict(415            cluster=cid, label=label,416            keyphrases=", ".join(kp_terms),417            overlap=", ".join(overlap) if overlap else "—",418            match_pct=round(match_pct * 100),419            status=status,420        ))421    return rows422 423 424# ---------------------------------------------------------------------------425# High-level pipeline entry point426# ---------------------------------------------------------------------------427def run_topic_modeling(filepath: str, n_trials: int = 50,428                       progress_callback=None) -> dict:429    # 1. Load430    df = load_csv(filepath)431    docs = prepare_documents(df)432    n_docs = len(docs)433 434    # 2. Embed (deterministic)435    embeddings = embed_documents(docs)436 437    # 3. Bayesian optimisation (§3.4)438    opt = run_bayesian_optimisation(embeddings, n_trials, progress_callback)439    bp = opt["best_params"]440    labels = opt["best_labels"]441 442    # 4. Re-run winner for clusterer object (probabilities)443    u = UMAP(n_neighbors=bp["n_neighbors"], n_components=bp["n_components"],444             min_dist=0.0, metric="cosine", random_state=42)445    red = u.fit_transform(embeddings)446    h = HDBSCAN(min_cluster_size=bp["min_cluster_size"],447                min_samples=bp["min_samples"], metric="euclidean",448                cluster_selection_method=bp["csm"],449                cluster_selection_epsilon=bp["cse"],450                allow_single_cluster=False,451                gen_min_span_tree=True)452    h.fit(red)453 454    # Per-cluster persistence (§8)455    cluster_pers = per_cluster_persistence(h, labels)456 457    # 5. Outlier reduction (§3.2 — clusters < 5 reassigned)458    labels = outlier_reduction(labels, red, n_docs)459 460    # 6. Strong / weak (§3.1)461    sw = strong_weak_members(labels, h.probabilities_)462 463    # 7. 2-D UMAP (§3.1)464    umap_2d = compute_2d_umap(embeddings)465 466    # 8. KeyBERT keyphrases (§3.1)467    keyphrases = extract_keyphrases(docs, labels)468 469    # 9. Rep docs470    rep_docs = get_representative_docs(labels, embeddings, docs)471 472    # 10. Final discipline473    disc = check_discipline(labels, n_docs)474 475    return dict(476        documents=docs, labels=labels.tolist(),477        keyphrases=keyphrases, representative_docs=rep_docs,478        membership=sw, umap_2d=umap_2d.tolist(),479        discipline=disc, best_params=bp,480        cluster_persistence=cluster_pers,481        metrics=dict(persistence=opt["persistence"],482                     dbcv=opt["dbcv"],483                     stability=opt["stability"]),484        trial_log=opt["trial_log"],485        n_trials_run=opt["n_trials_run"],486        best_trial=opt["best_trial"],487        n_docs=n_docs,488        embeddings=embeddings,489    )490