Spaces:
Running
Running
Download build_centroids.py from nikeshn/researchbee: direct link, hf CLI and curl.
- Browser
- Download file 4.74 kB
-
https://huggingface.co/spaces/nikeshn/researchbee/resolve/main/build_centroids.py
- Command line
-
hf download hf://spaces/nikeshn/researchbee/build_centroids.py
-
curl -L -o build_centroids.py https://huggingface.co/spaces/nikeshn/researchbee/resolve/main/build_centroids.py
4.74 kB
| # /// script | |
| # requires-python = ">=3.10" | |
| # dependencies = ["numpy", "sentence-transformers", "huggingface_hub"] | |
| # /// | |
| """ | |
| Turn the harvest into journal centroid vectors for the recommender's stage 2. | |
| python build_centroids.py --db journals.db \ | |
| --journal-text journal_text.jsonl --out centroids.npz | |
| Run this AFTER harvest_openalex.py. It does no network I/O of its own beyond | |
| downloading the sentence-transformer weights once. | |
| What gets embedded | |
| ------------------ | |
| Per journal, in descending order of signal: | |
| 1. OpenAlex topics, repeated in proportion to how much the journal publishes | |
| on each — an article-derived fingerprint, pre-aggregated by OpenAlex. | |
| 2. The journal's ASJC subject names, from the index. | |
| 3. The journal title. | |
| Journals OpenAlex could not match still get (2) and (3), so every journal has a | |
| vector and none silently drops out of the reranking. | |
| Cost note | |
| --------- | |
| all-MiniLM-L6-v2 is 384-dimensional and ~90 MB. 32,050 active journals × 384 | |
| float32 = ~49 MB uncompressed, well under what a Space can hold in memory. A | |
| larger model buys little here: the input is a bag of topic labels, not prose, | |
| so the extra capacity has nothing to work with. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import sqlite3 | |
| from pathlib import Path | |
| import numpy as np | |
| def load_topics(path: str) -> dict[str, str]: | |
| out: dict[str, str] = {} | |
| p = Path(path) | |
| if not p.exists(): | |
| print(f"note: {path} not found — building from index metadata only, " | |
| f"which is much weaker. Run harvest_openalex.py first.") | |
| return out | |
| with p.open(encoding="utf-8") as f: | |
| for line in f: | |
| try: | |
| rec = json.loads(line) | |
| except json.JSONDecodeError: | |
| continue | |
| sid = rec.get("scopus_id") | |
| if sid: | |
| out[sid] = (out.get(sid, "") + " " + rec.get("topics", "")).strip() | |
| return out | |
| def main() -> None: | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--db", default="journals.db") | |
| ap.add_argument("--journal-text", default="journal_text.jsonl") | |
| ap.add_argument("--out", default="centroids.npz") | |
| ap.add_argument("--model", default="sentence-transformers/all-MiniLM-L6-v2") | |
| ap.add_argument("--batch-size", type=int, default=128) | |
| ap.add_argument("--active-only", action="store_true", default=True) | |
| ap.add_argument("--push-to-dataset", default=None) | |
| args = ap.parse_args() | |
| topics = load_topics(args.journal_text) | |
| con = sqlite3.connect(args.db) | |
| subs: dict[str, list[str]] = {} | |
| for sid, name in con.execute( | |
| """SELECT ja.scopus_id, coalesce(a.name, a.parent_name) | |
| FROM journal_asjc ja JOIN asjc a ON a.code = ja.asjc""" | |
| ): | |
| subs.setdefault(sid, []).append(name or "") | |
| where = "WHERE active = 1" if args.active_only else "" | |
| rows = con.execute(f"SELECT scopus_id, title FROM journals {where}").fetchall() | |
| con.close() | |
| ids, docs = [], [] | |
| for sid, title in rows: | |
| parts = [title or ""] | |
| s = subs.get(sid) | |
| if s: | |
| parts.append(". ".join(sorted(set(s)))) | |
| t = topics.get(sid) | |
| if t: | |
| parts.append(t) | |
| ids.append(sid) | |
| docs.append(". ".join(p for p in parts if p)[:2000]) | |
| covered = sum(1 for i in ids if i in topics) | |
| print(f"{len(ids)} journals; {covered} ({covered/max(len(ids),1):.0%}) " | |
| f"have OpenAlex topics") | |
| from sentence_transformers import SentenceTransformer | |
| model = SentenceTransformer(args.model) | |
| vecs = model.encode( | |
| docs, batch_size=args.batch_size, show_progress_bar=True, | |
| normalize_embeddings=True, convert_to_numpy=True, | |
| ).astype("float32") | |
| np.savez_compressed(args.out, ids=np.array(ids), vecs=vecs) | |
| size = Path(args.out).stat().st_size / 1e6 | |
| print(f"wrote {args.out} {vecs.shape} ({size:.1f} MB)") | |
| if args.push_to_dataset: | |
| from huggingface_hub import HfApi | |
| api = HfApi() | |
| api.create_repo(args.push_to_dataset, repo_type="dataset", exist_ok=True) | |
| api.upload_file(path_or_fileobj=args.out, path_in_repo=Path(args.out).name, | |
| repo_id=args.push_to_dataset, repo_type="dataset") | |
| print(f"pushed {args.out} -> {args.push_to_dataset}") | |
| def load_centroids(path: str = "centroids.npz") -> dict[str, np.ndarray]: | |
| """Load into the {scopus_id: vector} mapping recommend() expects.""" | |
| d = np.load(path, allow_pickle=False) | |
| return {str(i): v for i, v in zip(d["ids"], d["vecs"])} | |
| if __name__ == "__main__": | |
| main() | |