nikeshn commited on
Commit
d0ed6b5
Β·
verified Β·
1 Parent(s): 021ffe6

Upload 6 files

Browse files
asjc_classifier.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Abstract -> ASJC subject codes, for populating ResearchBee's recommendation filter.
3
+
4
+ from asjc_classifier import AsjcClassifier
5
+
6
+ clf = AsjcClassifier("asjc_model.joblib") # local model only
7
+ clf = AsjcClassifier("asjc_model.joblib", llm=True) # + LLM adjudication
8
+
9
+ result = clf.classify(title=..., abstract=...)
10
+ result.codes # ['3309', '1710'] β€” feed straight to recommend(asjc=...)
11
+ result.method # 'linear+llm' | 'linear-topk'
12
+ result.candidates # what the LLM was asked to choose between
13
+
14
+ Architecture, and why it is not the obvious one
15
+ -----------------------------------------------
16
+ The obvious design is "run the linear model, escalate to an LLM when confidence
17
+ is low". Measured on the held-out abstract set, that design cannot work: the
18
+ top-1 probability averages 0.367 when the prediction is correct and 0.330 when
19
+ it is wrong. There is no threshold separating them β€” the model is wrong often
20
+ and unbothered about it.
21
+
22
+ What the model *is* good at is narrowing. A correct code appears within:
23
+
24
+ top-1 30%
25
+ top-3 70%
26
+ top-5 95% <- and flat thereafter
27
+
28
+ So it is used as a candidate generator, not a decider. The linear model cuts 334
29
+ codes to 5 with ~95% recall; the LLM picks among those 5. That inverts the usual
30
+ cost argument in our favour β€” the prompt carries five labelled options rather
31
+ than a 334-entry taxonomy, so the call is small, fast and hard to get wrong.
32
+
33
+ Degradation without an API key is graceful: `codes` becomes the top 3, which the
34
+ recommender applies as an OR filter. Recall is 70% rather than 95%, and the
35
+ candidate pool is broader, but nothing breaks.
36
+
37
+ Retrain after the OpenAlex harvest
38
+ ----------------------------------
39
+ The shipped model is trained on journal titles, because that is the labelled text
40
+ available without a network. It is applied to abstracts. `harvest_openalex.py`
41
+ produces the matched corpus; retraining on it and re-running `asjc_eval.jsonl`
42
+ measures the gain on the same 20 abstracts, so the upgrade is quantified rather
43
+ than assumed.
44
+ """
45
+ from __future__ import annotations
46
+
47
+ import json
48
+ import os
49
+ import re
50
+ from dataclasses import dataclass, field
51
+ from pathlib import Path
52
+
53
+ import joblib
54
+ import numpy as np
55
+ from scipy import sparse
56
+
57
+ __all__ = ["AsjcClassifier", "AsjcResult"]
58
+
59
+ #: Where the linear model's recall saturates. Measured, not guessed.
60
+ DEFAULT_K = 5
61
+ #: Codes returned when no LLM is available. top-3 = 70% recall.
62
+ FALLBACK_K = 3
63
+
64
+
65
+ @dataclass
66
+ class AsjcResult:
67
+ codes: list[str]
68
+ names: list[str]
69
+ method: str
70
+ candidates: list[dict] = field(default_factory=list)
71
+ llm_error: str | None = None
72
+
73
+ def __bool__(self) -> bool:
74
+ return bool(self.codes)
75
+
76
+
77
+ _PROMPT = """You are classifying an academic manuscript into Scopus ASJC subject areas.
78
+
79
+ A statistical model has narrowed 334 possible subject areas to the {n} candidates \
80
+ below. One or more of them is correct.
81
+
82
+ Candidates:
83
+ {candidates}
84
+
85
+ Manuscript title: {title}
86
+
87
+ Abstract: {abstract}
88
+
89
+ Choose the 1-3 candidates that best describe where this manuscript belongs, most \
90
+ appropriate first. Choose only from the candidate list β€” do not invent codes. If \
91
+ the manuscript is genuinely interdisciplinary, return more than one; if it clearly \
92
+ belongs to a single area, return just that one.
93
+
94
+ Respond with JSON only, no other text:
95
+ {{"codes": ["NNNN", ...], "reason": "one short sentence"}}"""
96
+
97
+
98
+ class AsjcClassifier:
99
+ def __init__(
100
+ self,
101
+ model_path: str | Path = "asjc_model.joblib",
102
+ llm: bool = False,
103
+ llm_model: str | None = None,
104
+ api_key: str | None = None,
105
+ ):
106
+ self.bundle = joblib.load(model_path)
107
+ self.classes: np.ndarray = self.bundle["classes"]
108
+ self.names: dict[str, str] = self.bundle["names"]
109
+ self.parent_of: dict[str, str] = self.bundle["parent_of"]
110
+ self.trained_on: str = self.bundle.get("corpus", "unknown")
111
+
112
+ self._client = None
113
+ # Model id is configuration, not a constant β€” set ASJC_LLM_MODEL to
114
+ # whichever model you are entitled to. Verify it against your account's
115
+ # available models rather than trusting a default baked in here.
116
+ self.llm_model = llm_model or os.getenv("ASJC_LLM_MODEL", "claude-sonnet-4-5")
117
+ if llm:
118
+ key = api_key or os.getenv("ANTHROPIC_API_KEY")
119
+ if key:
120
+ try:
121
+ import anthropic
122
+ self._client = anthropic.Anthropic(api_key=key)
123
+ except ImportError:
124
+ pass # falls back to top-k; reported in AsjcResult.method
125
+
126
+ # -- stage 1: narrow ----------------------------------------------------
127
+ def candidates(self, text: str, k: int = DEFAULT_K) -> list[dict]:
128
+ """The linear model's top-k, with names attached. ~95% recall at k=5."""
129
+ X = sparse.hstack([
130
+ self.bundle["word"].transform([text]),
131
+ self.bundle["char"].transform([text]),
132
+ ]).tocsr()
133
+ p = self.bundle["clf"].predict_proba(X)[0]
134
+ order = np.argsort(-p)[:k]
135
+ return [
136
+ {
137
+ "code": str(self.classes[i]),
138
+ "name": self.names.get(str(self.classes[i]), str(self.classes[i])),
139
+ "score": round(float(p[i]), 4),
140
+ }
141
+ for i in order
142
+ ]
143
+
144
+ # -- stage 2: adjudicate ------------------------------------------------
145
+ def classify(
146
+ self,
147
+ title: str = "",
148
+ abstract: str = "",
149
+ k: int = DEFAULT_K,
150
+ max_codes: int = 3,
151
+ ) -> AsjcResult:
152
+ text = f"{title} {title} {abstract}".strip()
153
+ if not text:
154
+ return AsjcResult([], [], "empty")
155
+
156
+ cands = self.candidates(text, k=k)
157
+ if not cands:
158
+ return AsjcResult([], [], "no-candidates")
159
+
160
+ if self._client is None:
161
+ picked = [c["code"] for c in cands[:FALLBACK_K]]
162
+ return AsjcResult(picked, [self.names.get(c, c) for c in picked],
163
+ "linear-topk", cands)
164
+
165
+ listing = "\n".join(f" {c['code']} {c['name']}" for c in cands)
166
+ prompt = _PROMPT.format(
167
+ n=len(cands), candidates=listing,
168
+ title=title or "(not supplied)",
169
+ abstract=(abstract or "(not supplied)")[:4000],
170
+ )
171
+ try:
172
+ msg = self._client.messages.create(
173
+ model=self.llm_model,
174
+ max_tokens=300,
175
+ temperature=0,
176
+ messages=[{"role": "user", "content": prompt}],
177
+ )
178
+ raw = "".join(b.text for b in msg.content if getattr(b, "type", "") == "text")
179
+ picked = self._parse(raw, {c["code"] for c in cands})[:max_codes]
180
+ if picked:
181
+ return AsjcResult(picked, [self.names.get(c, c) for c in picked],
182
+ "linear+llm", cands)
183
+ err = "LLM returned no valid code from the candidate set"
184
+ except Exception as e: # noqa: BLE001
185
+ err = f"{type(e).__name__}: {e}"
186
+
187
+ # Any LLM failure degrades to the statistical answer rather than raising.
188
+ # A broader-than-ideal subject filter is a far better outcome for the
189
+ # caller than a recommendation request that errors out.
190
+ picked = [c["code"] for c in cands[:FALLBACK_K]]
191
+ return AsjcResult(picked, [self.names.get(c, c) for c in picked],
192
+ "linear-topk", cands, llm_error=err)
193
+
194
+ @staticmethod
195
+ def _parse(raw: str, allowed: set[str]) -> list[str]:
196
+ """
197
+ Pull codes out of the reply and hard-filter to the candidate set.
198
+
199
+ The filter is the point: it makes an invented or hallucinated code
200
+ structurally impossible to return, so the worst case is fewer codes
201
+ rather than a wrong one that silently poisons the journal filter.
202
+ """
203
+ codes: list[str] = []
204
+ m = re.search(r"\{.*\}", raw, re.S)
205
+ if m:
206
+ try:
207
+ codes = [str(c).strip() for c in json.loads(m.group(0)).get("codes", [])]
208
+ except (json.JSONDecodeError, AttributeError, TypeError):
209
+ codes = []
210
+ if not codes:
211
+ codes = re.findall(r"\b\d{4}\b", raw)
212
+ out: list[str] = []
213
+ for c in codes:
214
+ if c in allowed and c not in out:
215
+ out.append(c)
216
+ return out
asjc_eval.jsonl ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {"id": "lis-chatbot", "field": "Library & Information Science", "accept": ["3309", "1710", "1706"], "text": "We present a retrieval-augmented generation chatbot integrated with the Primo discovery layer at a university library. The system combines dense vector retrieval over catalogue records and LibGuides content with a large language model to answer patron reference questions at the point of need. We evaluate retrieval precision, answer groundedness and user satisfaction across 1,200 real reference queries submitted by academic library users over one semester."}
2
+ {"id": "bibliometrics", "field": "Scientometrics", "accept": ["3309", "1706", "3312"], "text": "This scientometric analysis maps the growth of research output across national research systems using bibliometric indicators derived from Scopus and Web of Science. We compute field-normalised citation impact, international collaboration rates and disciplinary specialisation indices for 42 countries between 2000 and 2024, and examine the relationship between public research funding intensity and scholarly productivity."}
3
+ {"id": "cardiology-rct", "field": "Cardiology", "accept": ["2705", "2700", "2736"], "text": "This multicentre randomised controlled trial evaluated the efficacy and safety of direct oral anticoagulation in patients with non-valvular atrial fibrillation. 840 patients were randomised to receive either apixaban or dose-adjusted warfarin, with a primary composite endpoint of ischaemic stroke or systemic embolism at 24 months. Cardiovascular mortality, major bleeding and all-cause hospitalisation were prespecified secondary outcomes."}
4
+ {"id": "perovskite", "field": "Materials / Energy", "accept": ["2504", "2500", "2508", "2102", "3104", "3107", "1600", "2200"], "text": "We report a perovskite solar cell architecture achieving 25.6% power conversion efficiency. Thin-film deposition of methylammonium lead iodide was optimised through antisolvent engineering and interfacial passivation, and long-term device stability under continuous one-sun illumination was characterised over 1000 hours using X-ray diffraction, photoluminescence spectroscopy and impedance analysis."}
5
+ {"id": "nlp-transformer", "field": "Computer Science / AI", "accept": ["1702", "1706", "1712", "1700"], "text": "We introduce a parameter-efficient fine-tuning method for large transformer language models that adapts fewer than 0.1% of weights while matching full fine-tuning performance on downstream natural language understanding benchmarks. We evaluate on GLUE and SuperGLUE, analyse the rank structure of the learned updates, and show consistent gains in low-resource transfer settings."}
6
+ {"id": "groundwater", "field": "Earth / Environmental Science", "accept": ["2312", "1908", "1909", "1900", "2300", "2305", "2215"], "text": "We assess groundwater recharge dynamics in an arid alluvial aquifer using a coupled unsaturated-zone and finite-difference flow model calibrated against a decade of piezometric observations. Stable isotope signatures and chloride mass balance were used to partition recharge sources, and abstraction scenarios were simulated to evaluate long-term aquifer sustainability under projected climate forcing."}
7
+ {"id": "nursing-education", "field": "Nursing", "accept": ["2900", "2922", "2901", "3304", "2908"], "text": "This mixed-methods study examined the effect of high-fidelity simulation on clinical reasoning among undergraduate nursing students. Ninety-four students completed simulated deteriorating-patient scenarios followed by structured debriefing. Clinical reasoning was assessed with a validated rubric, and semi-structured interviews explored students' experiences of psychological safety during debriefing."}
8
+ {"id": "corporate-finance", "field": "Economics & Finance", "accept": ["2003", "2002", "2000", "1402", "1403"], "text": "We examine the effect of corporate governance quality on the cost of debt financing using a panel of listed firms across 18 emerging markets. Employing instrumental-variable estimation to address endogeneity, we find that board independence and disclosure quality are associated with materially lower credit spreads, with the effect concentrated among firms facing greater information asymmetry."}
9
+ {"id": "medieval-history", "field": "History / Humanities", "accept": ["1202", "1200", "1208"], "text": "Drawing on previously unexamined chancery records, this article reconstructs patterns of monastic landholding in the late medieval Rhineland. It argues that the conventional narrative of continuous ecclesiastical accumulation obscures a sustained period of alienation driven by fiscal pressure, and situates these transactions within wider debates about the economic agency of religious houses."}
10
+ {"id": "sociolinguistics", "field": "Linguistics / Arts & Humanities", "accept": ["3310", "1203", "1200", "3316"], "text": "This study investigates code-switching practices among second-generation multilingual speakers in an urban migration context. Using a corpus of naturalistic conversational recordings, we analyse patterns of intra-sentential switching and examine how speakers deploy linguistic resources to index identity and negotiate group membership across community settings."}
11
+ {"id": "supply-chain", "field": "Operations / Decision Sciences", "accept": ["1803", "1800", "1802", "2209", "1408"], "text": "We formulate a stochastic mixed-integer programming model for resilient supply chain network design under correlated disruption risk. A Benders decomposition scheme with valid inequalities is developed to solve realistically sized instances, and computational experiments on a multi-echelon distribution case demonstrate substantial cost savings over deterministic and robust-optimisation baselines."}
12
+ {"id": "immunology", "field": "Immunology & Microbiology", "accept": ["2403", "2404", "2400", "2406", "1312"], "text": "We characterise the tissue-resident memory T cell compartment in the human respiratory mucosa following viral infection. Single-cell RNA sequencing combined with flow cytometry revealed transcriptionally distinct subsets with divergent cytokine profiles, and adoptive transfer experiments in a murine model demonstrated that these populations confer durable protection against heterologous challenge."}
13
+ {"id": "civil-engineering", "field": "Civil / Structural Engineering", "accept": ["2205", "2200", "2215", "2210"], "text": "This paper presents a performance-based seismic assessment of reinforced concrete moment frames designed to pre-1980 detailing provisions. Nonlinear time-history analyses were conducted across a suite of ground motions scaled to multiple hazard levels, and fragility surfaces were developed to quantify collapse probability and inform retrofit prioritisation for existing building stock."}
14
+ {"id": "education-policy", "field": "Education", "accept": ["3304", "3300", "3312"], "text": "We evaluate the impact of a national school autonomy reform on student attainment using a difference-in-differences design across administrative regions. Drawing on eight years of standardised assessment data linked to school-level governance records, we find heterogeneous effects that vary systematically with baseline school capacity and local labour market conditions."}
15
+ {"id": "pharmacology", "field": "Pharmacology & Pharmaceutics", "accept": ["3004", "3003", "3002", "3000", "1313"], "text": "We report the pharmacokinetic and pharmacodynamic characterisation of a novel small-molecule kinase inhibitor in preclinical models. Dose-ranging studies established plasma exposure and tissue distribution, hepatic microsomal assays quantified metabolic stability and CYP inhibition liability, and target engagement was confirmed by phosphoproteomic profiling in tumour xenografts."}
16
+ {"id": "psychology-cognition", "field": "Psychology / Neuroscience", "accept": ["3205", "3206", "3200", "2805", "2802"], "text": "Across three preregistered experiments we examined how working memory load modulates attentional capture by emotionally salient distractors. Participants performed a visual search task under concurrent memory demand while eye movements were recorded. Results indicate that capture persists under high load for threat-related stimuli but is attenuated for neutral salient items."}
17
+ {"id": "agriculture", "field": "Agricultural & Biological Sciences", "accept": ["1111", "1102", "1100", "2300", "1106"], "text": "We evaluate the effect of conservation tillage and cover cropping on soil organic carbon stocks and crop yield stability across a nine-year field experiment in a semi-arid cropping system. Soil sampling at multiple depths, combined with yield gap decomposition, indicates that carbon accrual is concentrated in surface horizons and that yield benefits emerge only in drier seasons."}
18
+ {"id": "veterinary", "field": "Veterinary", "accept": ["3404", "3400", "3401", "3403"], "text": "This retrospective cohort study describes clinical presentation, treatment and outcome in dogs diagnosed with immune-mediated haemolytic anaemia at a referral hospital over twelve years. Prognostic factors were assessed by multivariable survival analysis, and the association between transfusion requirement, thromboembolic complications and 30-day mortality was quantified."}
19
+ {"id": "dentistry", "field": "Dentistry", "accept": ["3504", "3506", "3500", "3501"], "text": "We compared marginal bone loss around platform-switched and conventional implant-abutment connections in a randomised split-mouth clinical trial. Sixty implants were placed in thirty partially edentulous patients, with standardised periapical radiography at baseline, six and twenty-four months, and peri-implant soft tissue parameters recorded alongside patient-reported outcomes."}
20
+ {"id": "open-access-policy", "field": "LIS / Scholarly Communication", "accept": ["3309", "3312", "1710", "3300"], "text": "This study analyses institutional compliance with funder open access mandates across a national university system. Combining repository deposit records with publisher policy data from Open Policy Finder, we quantify green and gold route uptake, embargo adherence and article processing charge expenditure, and identify institutional factors associated with higher compliance rates."}
build_centroids.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = ["numpy", "sentence-transformers", "huggingface_hub"]
4
+ # ///
5
+ """
6
+ Turn the harvest into journal centroid vectors for the recommender's stage 2.
7
+
8
+ python build_centroids.py --db journals.db \
9
+ --journal-text journal_text.jsonl --out centroids.npz
10
+
11
+ Run this AFTER harvest_openalex.py. It does no network I/O of its own beyond
12
+ downloading the sentence-transformer weights once.
13
+
14
+ What gets embedded
15
+ ------------------
16
+ Per journal, in descending order of signal:
17
+
18
+ 1. OpenAlex topics, repeated in proportion to how much the journal publishes
19
+ on each β€” an article-derived fingerprint, pre-aggregated by OpenAlex.
20
+ 2. The journal's ASJC subject names, from the index.
21
+ 3. The journal title.
22
+
23
+ Journals OpenAlex could not match still get (2) and (3), so every journal has a
24
+ vector and none silently drops out of the reranking.
25
+
26
+ Cost note
27
+ ---------
28
+ all-MiniLM-L6-v2 is 384-dimensional and ~90 MB. 32,050 active journals Γ— 384
29
+ float32 = ~49 MB uncompressed, well under what a Space can hold in memory. A
30
+ larger model buys little here: the input is a bag of topic labels, not prose,
31
+ so the extra capacity has nothing to work with.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import json
37
+ import sqlite3
38
+ from pathlib import Path
39
+
40
+ import numpy as np
41
+
42
+
43
+ def load_topics(path: str) -> dict[str, str]:
44
+ out: dict[str, str] = {}
45
+ p = Path(path)
46
+ if not p.exists():
47
+ print(f"note: {path} not found β€” building from index metadata only, "
48
+ f"which is much weaker. Run harvest_openalex.py first.")
49
+ return out
50
+ with p.open(encoding="utf-8") as f:
51
+ for line in f:
52
+ try:
53
+ rec = json.loads(line)
54
+ except json.JSONDecodeError:
55
+ continue
56
+ sid = rec.get("scopus_id")
57
+ if sid:
58
+ out[sid] = (out.get(sid, "") + " " + rec.get("topics", "")).strip()
59
+ return out
60
+
61
+
62
+ def main() -> None:
63
+ ap = argparse.ArgumentParser()
64
+ ap.add_argument("--db", default="journals.db")
65
+ ap.add_argument("--journal-text", default="journal_text.jsonl")
66
+ ap.add_argument("--out", default="centroids.npz")
67
+ ap.add_argument("--model", default="sentence-transformers/all-MiniLM-L6-v2")
68
+ ap.add_argument("--batch-size", type=int, default=128)
69
+ ap.add_argument("--active-only", action="store_true", default=True)
70
+ ap.add_argument("--push-to-dataset", default=None)
71
+ args = ap.parse_args()
72
+
73
+ topics = load_topics(args.journal_text)
74
+ con = sqlite3.connect(args.db)
75
+
76
+ subs: dict[str, list[str]] = {}
77
+ for sid, name in con.execute(
78
+ """SELECT ja.scopus_id, coalesce(a.name, a.parent_name)
79
+ FROM journal_asjc ja JOIN asjc a ON a.code = ja.asjc"""
80
+ ):
81
+ subs.setdefault(sid, []).append(name or "")
82
+
83
+ where = "WHERE active = 1" if args.active_only else ""
84
+ rows = con.execute(f"SELECT scopus_id, title FROM journals {where}").fetchall()
85
+ con.close()
86
+
87
+ ids, docs = [], []
88
+ for sid, title in rows:
89
+ parts = [title or ""]
90
+ s = subs.get(sid)
91
+ if s:
92
+ parts.append(". ".join(sorted(set(s))))
93
+ t = topics.get(sid)
94
+ if t:
95
+ parts.append(t)
96
+ ids.append(sid)
97
+ docs.append(". ".join(p for p in parts if p)[:2000])
98
+
99
+ covered = sum(1 for i in ids if i in topics)
100
+ print(f"{len(ids)} journals; {covered} ({covered/max(len(ids),1):.0%}) "
101
+ f"have OpenAlex topics")
102
+
103
+ from sentence_transformers import SentenceTransformer
104
+ model = SentenceTransformer(args.model)
105
+ vecs = model.encode(
106
+ docs, batch_size=args.batch_size, show_progress_bar=True,
107
+ normalize_embeddings=True, convert_to_numpy=True,
108
+ ).astype("float32")
109
+
110
+ np.savez_compressed(args.out, ids=np.array(ids), vecs=vecs)
111
+ size = Path(args.out).stat().st_size / 1e6
112
+ print(f"wrote {args.out} {vecs.shape} ({size:.1f} MB)")
113
+
114
+ if args.push_to_dataset:
115
+ from huggingface_hub import HfApi
116
+ api = HfApi()
117
+ api.create_repo(args.push_to_dataset, repo_type="dataset", exist_ok=True)
118
+ api.upload_file(path_or_fileobj=args.out, path_in_repo=Path(args.out).name,
119
+ repo_id=args.push_to_dataset, repo_type="dataset")
120
+ print(f"pushed {args.out} -> {args.push_to_dataset}")
121
+
122
+
123
+ def load_centroids(path: str = "centroids.npz") -> dict[str, np.ndarray]:
124
+ """Load into the {scopus_id: vector} mapping recommend() expects."""
125
+ d = np.load(path, allow_pickle=False)
126
+ return {str(i): v for i, v in zip(d["ids"], d["vecs"])}
127
+
128
+
129
+ if __name__ == "__main__":
130
+ main()
harvest_openalex.py ADDED
@@ -0,0 +1,353 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = ["requests", "huggingface_hub"]
4
+ # ///
5
+ """
6
+ One OpenAlex pass that produces BOTH deliverables:
7
+
8
+ journal_text.jsonl topical fingerprint per journal -> centroids (recommender stage 2)
9
+ asjc_train.jsonl labelled article abstracts -> ASJC classifier training
10
+
11
+ python harvest_openalex.py --db journals.db --mailto you@ku.ac.ae
12
+ python harvest_openalex.py --db journals.db --mailto you@ku.ac.ae --resume
13
+
14
+ These are the same job. The centroids need to know what each journal publishes
15
+ about; the classifier needs text labelled with subject codes. Both are answered
16
+ by reading OpenAlex once. Running them as separate harvests would double the
17
+ request count for no benefit.
18
+
19
+ Two phases
20
+ ----------
21
+ PHASE 1 sources ~1,400 requests, minutes.
22
+ Cursor-pages every journal source and takes its `topics`, which OpenAlex has
23
+ already inferred from that journal's article corpus. An article-derived
24
+ signal delivered pre-aggregated β€” most of the value of reading the articles,
25
+ at ~4% of the requests.
26
+
27
+ PHASE 2 works ~8,000 requests, 1-2 hours.
28
+ Pulls recent abstracts per journal to train the classifier. Sampling is
29
+ STRATIFIED BY ASJC CODE, not uniform over journals: ASJC is wildly
30
+ imbalanced (History has 2,009 journals, Medical Terminology has 2), and
31
+ uniform sampling would produce a classifier that has never seen the rare
32
+ half of the taxonomy. `--per-code` journals are drawn per subject area, so
33
+ thin codes get represented.
34
+
35
+ Resumability
36
+ ------------
37
+ HF Space containers restart without warning, and a two-hour harvest that loses
38
+ everything on restart is not a harvest. Both outputs are append-only JSONL and
39
+ progress is checkpointed to `<out>.state.json` after every batch. `--resume`
40
+ picks up from the last checkpoint. Re-running without `--resume` starts over.
41
+
42
+ Rate limits
43
+ -----------
44
+ OpenAlex allows 100k requests/day and ~10/sec in the polite pool. `--mailto` is
45
+ what puts you in that pool β€” without it you are throttled harder and less
46
+ predictably. It is required for a reason.
47
+
48
+ Where to run this
49
+ -----------------
50
+ Not inside a Space. A Space's disk is ephemeral β€” "content is lost if your Space
51
+ restarts or is stopped" β€” so a two-hour harvest writing 250k abstracts there is
52
+ one restart away from nothing, and free Spaces give you no terminal to launch it
53
+ from. Persistent storage on a Space is a paid Storage Bucket.
54
+
55
+ Run it as an HF Job instead β€” same infrastructure, purpose-built for exactly
56
+ this, billed per second:
57
+
58
+ hf jobs uv run --with requests,huggingface_hub \\
59
+ harvest_openalex.py -- --db journals.db --mailto you@ku.ac.ae \\
60
+ --push-to-dataset nikeshn/researchbee-index
61
+
62
+ The PEP 723 header above declares the dependencies, so `hf jobs uv run` resolves
63
+ them without a Dockerfile. `--push-to-dataset` uploads the outputs to a Dataset
64
+ repo as they complete, which is what makes the run durable: the Job container is
65
+ as ephemeral as the Space, so anything not pushed is lost when it exits.
66
+ """
67
+ from __future__ import annotations
68
+
69
+ import argparse
70
+ import json
71
+ import random
72
+ import sqlite3
73
+ import time
74
+ from collections import defaultdict
75
+ from pathlib import Path
76
+
77
+ import requests
78
+
79
+ API = "https://api.openalex.org"
80
+ SESSION = requests.Session()
81
+ SESSION.headers.update({"User-Agent": "ResearchBee/1.0 (journal index build)"})
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # HTTP
86
+ # ---------------------------------------------------------------------------
87
+ def get(url: str, params: dict, mailto: str, tries: int = 5) -> dict:
88
+ params = {**params, "mailto": mailto}
89
+ for attempt in range(tries):
90
+ try:
91
+ r = SESSION.get(url, params=params, timeout=60)
92
+ if r.status_code in (429, 503):
93
+ time.sleep(min(60, 2 ** attempt * 2))
94
+ continue
95
+ r.raise_for_status()
96
+ return r.json()
97
+ except requests.RequestException:
98
+ if attempt == tries - 1:
99
+ return {}
100
+ time.sleep(min(60, 2 ** attempt))
101
+ return {}
102
+
103
+
104
+ def reconstruct_abstract(inv: dict | None) -> str:
105
+ """
106
+ OpenAlex ships abstracts as an inverted index ({word: [positions]}) because
107
+ of publisher redistribution terms. Rebuild the linear text.
108
+ """
109
+ if not inv:
110
+ return ""
111
+ positions: list[tuple[int, str]] = []
112
+ for word, idxs in inv.items():
113
+ for i in idxs:
114
+ positions.append((i, word))
115
+ if not positions:
116
+ return ""
117
+ positions.sort()
118
+ return " ".join(w for _, w in positions)
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # Checkpointing
123
+ # ---------------------------------------------------------------------------
124
+ class Checkpoint:
125
+ def __init__(self, path: Path, resume: bool):
126
+ self.path = path
127
+ self.state = {"phase": 1, "cursor": "*", "done_journals": [], "n_works": 0}
128
+ if resume and path.exists():
129
+ self.state = json.loads(path.read_text())
130
+ print(f"resuming: phase {self.state['phase']}, "
131
+ f"{len(self.state['done_journals'])} journals done, "
132
+ f"{self.state['n_works']} abstracts written")
133
+
134
+ def save(self) -> None:
135
+ tmp = self.path.with_suffix(".tmp")
136
+ tmp.write_text(json.dumps(self.state))
137
+ tmp.replace(self.path) # atomic β€” a restart mid-write must not corrupt it
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Index helpers
142
+ # ---------------------------------------------------------------------------
143
+ def issn_to_scopus(con: sqlite3.Connection) -> dict[str, str]:
144
+ m: dict[str, str] = {}
145
+ for sid, p, e in con.execute(
146
+ "SELECT scopus_id, issn_print, issn_e FROM journals WHERE active = 1"
147
+ ):
148
+ for i in (p, e):
149
+ if i:
150
+ m.setdefault(f"{i[:4]}-{i[4:]}", sid)
151
+ return m
152
+
153
+
154
+ def stratified_journals(con: sqlite3.Connection, per_code: int, seed: int) -> list[tuple[str, str]]:
155
+ """
156
+ Pick up to `per_code` journals per ASJC code, preferring higher-SJR titles
157
+ (better-edited abstracts, cleaner topical signal), then de-duplicate.
158
+ Returns [(scopus_id, issn)].
159
+ """
160
+ rng = random.Random(seed)
161
+ by_code: dict[str, list[tuple[str, str]]] = defaultdict(list)
162
+ for sid, issn, code in con.execute(
163
+ """SELECT j.scopus_id, coalesce(j.issn_e, j.issn_print), ja.asjc
164
+ FROM journals j JOIN journal_asjc ja ON ja.scopus_id = j.scopus_id
165
+ WHERE j.active = 1 AND j.source_type = 'Journal'
166
+ AND coalesce(j.issn_e, j.issn_print) IS NOT NULL
167
+ AND j.has_english = 1
168
+ ORDER BY j.sjr DESC NULLS LAST"""
169
+ ):
170
+ by_code[code].append((sid, issn))
171
+
172
+ picked: dict[str, str] = {}
173
+ for code, rows in by_code.items():
174
+ # Take the strongest half, then sample within it, so the corpus is not
175
+ # entirely the same handful of megajournals per field.
176
+ pool = rows[: max(per_code * 3, per_code)]
177
+ rng.shuffle(pool)
178
+ for sid, issn in pool[:per_code]:
179
+ picked.setdefault(sid, issn)
180
+ return sorted(picked.items())
181
+
182
+
183
+ # ---------------------------------------------------------------------------
184
+ # Phase 1 β€” journal topics (centroids)
185
+ # ---------------------------------------------------------------------------
186
+ def phase1(con, mailto: str, out: Path, ck: Checkpoint) -> None:
187
+ keymap = issn_to_scopus(con)
188
+ seen_ids: set[str] = set()
189
+ if out.exists() and ck.state["phase"] > 1:
190
+ return
191
+ fh = out.open("a", encoding="utf-8")
192
+ if out.exists():
193
+ with out.open(encoding="utf-8") as f:
194
+ for line in f:
195
+ try:
196
+ seen_ids.add(json.loads(line)["scopus_id"])
197
+ except (json.JSONDecodeError, KeyError):
198
+ pass
199
+
200
+ cursor = ck.state.get("cursor") or "*"
201
+ page = 0
202
+ while cursor:
203
+ data = get(f"{API}/sources", {
204
+ "filter": "type:journal", "per-page": 200, "cursor": cursor,
205
+ "select": "id,display_name,issn,topics",
206
+ }, mailto)
207
+ results = data.get("results", [])
208
+ if not results:
209
+ break
210
+ for s in results:
211
+ sid = next((keymap[i] for i in (s.get("issn") or []) if i in keymap), None)
212
+ if not sid or sid in seen_ids:
213
+ continue
214
+ terms: list[str] = []
215
+ for t in (s.get("topics") or [])[:12]:
216
+ n = int(t.get("count", 1) or 1)
217
+ # Repeat proportional to prevalence so the embedding reflects
218
+ # what the journal mostly publishes, not its long tail.
219
+ terms += [t.get("display_name", "")] * max(1, min(4, n // 50))
220
+ for lvl in ("subfield", "field"):
221
+ if (t.get(lvl) or {}).get("display_name"):
222
+ terms.append(t[lvl]["display_name"])
223
+ if terms:
224
+ fh.write(json.dumps({"scopus_id": sid, "topics": " ".join(terms)}) + "\n")
225
+ seen_ids.add(sid)
226
+ fh.flush()
227
+ cursor = (data.get("meta") or {}).get("next_cursor")
228
+ ck.state["cursor"] = cursor
229
+ ck.save()
230
+ page += 1
231
+ if page % 10 == 0:
232
+ print(f" phase1 page {page} matched {len(seen_ids)}", flush=True)
233
+
234
+ fh.close()
235
+ ck.state.update(phase=2, cursor=None)
236
+ ck.save()
237
+ print(f"phase 1 done: {len(seen_ids)} journals with topics")
238
+
239
+
240
+ # ---------------------------------------------------------------------------
241
+ # Phase 2 β€” labelled abstracts (classifier)
242
+ # ---------------------------------------------------------------------------
243
+ def phase2(con, mailto: str, out: Path, ck: Checkpoint,
244
+ per_code: int, per_journal: int, seed: int, from_year: int) -> None:
245
+ targets = stratified_journals(con, per_code, seed)
246
+ done = set(ck.state["done_journals"])
247
+ todo = [(s, i) for s, i in targets if s not in done]
248
+ print(f"phase 2: {len(targets)} journals selected, {len(todo)} remaining")
249
+
250
+ fh = out.open("a", encoding="utf-8")
251
+ for n, (sid, issn) in enumerate(todo, 1):
252
+ fmt = f"{issn[:4]}-{issn[4:]}"
253
+ data = get(f"{API}/works", {
254
+ "filter": (f"primary_location.source.issn:{fmt},"
255
+ f"from_publication_date:{from_year}-01-01,"
256
+ "type:article,has_abstract:true"),
257
+ "per-page": per_journal,
258
+ "select": "title,abstract_inverted_index",
259
+ }, mailto)
260
+ wrote = 0
261
+ for w in data.get("results", []):
262
+ abstract = reconstruct_abstract(w.get("abstract_inverted_index"))
263
+ if len(abstract) < 200:
264
+ continue
265
+ text = f"{w.get('title') or ''}. {abstract}".strip()
266
+ fh.write(json.dumps({"scopus_id": sid, "text": text[:4000]}) + "\n")
267
+ wrote += 1
268
+ ck.state["n_works"] += wrote
269
+ done.add(sid)
270
+
271
+ if n % 25 == 0:
272
+ fh.flush()
273
+ ck.state["done_journals"] = sorted(done)
274
+ ck.save()
275
+ print(f" phase2 {n}/{len(todo)} journals, "
276
+ f"{ck.state['n_works']} abstracts", flush=True)
277
+ time.sleep(0.11) # ~9 req/s, inside the polite-pool ceiling
278
+
279
+ fh.flush()
280
+ fh.close()
281
+ ck.state["done_journals"] = sorted(done)
282
+ ck.state["phase"] = 3
283
+ ck.save()
284
+ print(f"phase 2 done: {ck.state['n_works']} abstracts")
285
+
286
+
287
+ def main() -> None:
288
+ ap = argparse.ArgumentParser()
289
+ ap.add_argument("--db", default="journals.db")
290
+ ap.add_argument("--mailto", required=True,
291
+ help="your email β€” required for OpenAlex's polite pool")
292
+ ap.add_argument("--resume", action="store_true")
293
+ ap.add_argument("--skip-phase1", action="store_true")
294
+ ap.add_argument("--skip-phase2", action="store_true")
295
+ ap.add_argument("--journal-text", default="journal_text.jsonl")
296
+ ap.add_argument("--train-out", default="asjc_train.jsonl")
297
+ ap.add_argument("--per-code", type=int, default=25,
298
+ help="journals sampled per ASJC code (stratification depth)")
299
+ ap.add_argument("--per-journal", type=int, default=40,
300
+ help="abstracts pulled per journal")
301
+ ap.add_argument("--from-year", type=int, default=2021)
302
+ ap.add_argument("--seed", type=int, default=42)
303
+ ap.add_argument("--push-to-dataset", default=None,
304
+ help="HF Dataset repo id to upload outputs to, e.g. user/researchbee-index. "
305
+ "Required for Job runs β€” the container's disk does not survive it.")
306
+ args = ap.parse_args()
307
+
308
+ con = sqlite3.connect(args.db)
309
+ ck = Checkpoint(Path(args.train_out + ".state.json"), args.resume)
310
+
311
+ if not args.resume:
312
+ for p in (args.journal_text, args.train_out):
313
+ Path(p).unlink(missing_ok=True)
314
+
315
+ if not args.skip_phase1 and ck.state["phase"] <= 1:
316
+ phase1(con, args.mailto, Path(args.journal_text), ck)
317
+ if not args.skip_phase2 and ck.state["phase"] <= 2:
318
+ phase2(con, args.mailto, Path(args.train_out), ck,
319
+ args.per_code, args.per_journal, args.seed, args.from_year)
320
+
321
+ for p in (args.journal_text, args.train_out):
322
+ path = Path(p)
323
+ if path.exists():
324
+ n = sum(1 for _ in path.open(encoding="utf-8"))
325
+ print(f"{p}: {n} lines, {path.stat().st_size/1e6:.1f} MB")
326
+ con.close()
327
+
328
+ if args.push_to_dataset:
329
+ push(args.push_to_dataset, [args.journal_text, args.train_out,
330
+ args.train_out + ".state.json"])
331
+
332
+
333
+ def push(repo_id: str, paths: list[str]) -> None:
334
+ """
335
+ Upload outputs to a Dataset repo. This is not a convenience β€” a Job's disk
336
+ is discarded when the run ends, so anything not pushed did not happen.
337
+ """
338
+ from huggingface_hub import HfApi
339
+ api = HfApi()
340
+ api.create_repo(repo_id, repo_type="dataset", exist_ok=True)
341
+ for p in paths:
342
+ path = Path(p)
343
+ if not path.exists():
344
+ continue
345
+ api.upload_file(
346
+ path_or_fileobj=str(path), path_in_repo=path.name,
347
+ repo_id=repo_id, repo_type="dataset",
348
+ )
349
+ print(f"pushed {path.name} -> {repo_id}")
350
+
351
+
352
+ if __name__ == "__main__":
353
+ main()
researchbee_journals.py ADDED
@@ -0,0 +1,602 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ResearchBee journal index + recommender.
3
+
4
+ from researchbee_journals import JournalIndex
5
+
6
+ idx = JournalIndex("journals.db")
7
+ idx.lookup(issn="0737-8831") # -> JournalRecord (Library Hi Tech)
8
+ idx.recommend(title=..., abstract=...) # -> [Recommendation, ...]
9
+
10
+ Architecture
11
+ ------------
12
+ Three stages, deliberately separated so the expensive one stays optional:
13
+
14
+ 1. CANDIDATE GENERATION β€” hard ASJC filter (when subject codes are known) plus
15
+ FTS5/BM25 over title + publisher + subject names. Pure SQLite, no model,
16
+ sub-10ms, returns a few hundred candidates.
17
+
18
+ 2. SEMANTIC RERANK β€” optional. Cosine similarity between the manuscript
19
+ embedding and a per-journal centroid. See the honest caveat below.
20
+
21
+ 3. FIT SCORING β€” quartile, indexation state, OA status, coverage
22
+ recency, language, publisher agreement coverage. Deterministic, explainable,
23
+ and the part that should drive the text ResearchBee shows the researcher.
24
+
25
+ The caveat on stage 2
26
+ ---------------------
27
+ The Scopus Source List carries no aims-and-scope text. Embedding a journal
28
+ *title* against a manuscript *abstract* is a weak signal β€” it recovers obvious
29
+ lexical matches ("Journal of Hydrology" for a hydrology paper) and little else.
30
+ Real matchers (Jane, Elsevier Journal Finder, WoS Match Manuscript) compare the
31
+ manuscript against *articles the journal has published*. To reach that quality
32
+ you need article-level text, which means building journal centroids from OpenAlex
33
+ works β€” `build_centroids.py` does this. Until centroids exist, `recommend()`
34
+ runs stages 1 and 3 only and says so in `Recommendation.method`, rather than
35
+ quietly returning title-similarity noise dressed up as semantic matching.
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import re
40
+ import sqlite3
41
+ import threading
42
+ from dataclasses import dataclass, field, asdict
43
+ from pathlib import Path
44
+ from typing import Iterable, Sequence
45
+
46
+ __all__ = ["JournalIndex", "JournalRecord", "Recommendation", "IndexationState"]
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Indexation state β€” the distinction the old lookup collapsed
51
+ # ---------------------------------------------------------------------------
52
+ class IndexationState:
53
+ """
54
+ Why this exists: 'no quartile' is three materially different answers to a
55
+ researcher deciding where to submit.
56
+
57
+ INDEXED_RANKED in Scopus, has a current SJR and quartile. (30,405)
58
+ INDEXED_NEW in Scopus, coverage began 2024+, SJR not yet computed.
59
+ SJR needs a ~3-year citation window, so the absence is a
60
+ timing artefact, not a quality signal. Safe to submit to.
61
+ (326)
62
+ INDEXED_UNRANKED SCImago has a record but computes no SJR β€” typically below
63
+ its citable-document threshold. Thin but real. (279)
64
+ INDEXED_NO_SJR in Scopus, absent from SCImago entirely. Overwhelmingly
65
+ book series (1,052), which SJR does not rank at all. Only
66
+ ~70 are long-running journals, mostly regional titles.
67
+ (1,366)
68
+ NOT_INDEXED no Scopus source record at all, active or inactive.
69
+
70
+ And three states for titles Scopus no longer indexes. Keeping them apart is
71
+ the whole point: of 16,838 inactive titles only 967 were removed for cause.
72
+ Telling a researcher that a journal which merely changed its name is a
73
+ quality risk is worse than saying nothing.
74
+
75
+ DISCONTINUED removed by Scopus for cause. The real red flag. (967)
76
+ RENAMED ceased under this title but the work continues elsewhere;
77
+ `successor_title` names where. Benign β€” redirect, don't
78
+ warn. (~7,000 with a resolvable successor)
79
+ CEASED stopped being indexed, no successor recorded and no
80
+ discontinuation flag. Usually a journal that folded.
81
+ """
82
+ INDEXED_RANKED = "indexed_ranked"
83
+ INDEXED_NEW = "indexed_new"
84
+ INDEXED_UNRANKED = "indexed_unranked"
85
+ INDEXED_NO_SJR = "indexed_no_sjr"
86
+ NOT_INDEXED = "not_indexed"
87
+ DISCONTINUED = "discontinued"
88
+ RENAMED = "renamed"
89
+ CEASED = "ceased"
90
+
91
+ #: States a prospective author should be actively warned about.
92
+ WARN = frozenset({DISCONTINUED})
93
+ #: States meaning "Scopus does not index new articles here".
94
+ NOT_CURRENTLY_INDEXED = frozenset({DISCONTINUED, RENAMED, CEASED, NOT_INDEXED})
95
+
96
+ MESSAGES = {
97
+ INDEXED_RANKED: "Indexed in Scopus with a current SJR quartile.",
98
+ INDEXED_NEW: (
99
+ "Indexed in Scopus, but coverage is too recent for an SJR quartile. "
100
+ "SJR requires roughly three years of citation data; expect a first "
101
+ "quartile assignment once that window closes. The missing quartile "
102
+ "is not a quality signal."
103
+ ),
104
+ INDEXED_UNRANKED: (
105
+ "Indexed in Scopus and listed by SCImago, but no SJR is computed β€” "
106
+ "usually because the title publishes too few citable documents to "
107
+ "meet SCImago's threshold."
108
+ ),
109
+ INDEXED_NO_SJR: (
110
+ "Indexed in Scopus but absent from SCImago. Normal for book series "
111
+ "and trade journals, which SJR does not rank. Check the source type "
112
+ "before treating this as a concern."
113
+ ),
114
+ NOT_INDEXED: (
115
+ "No Scopus source record found β€” this title is not indexed in "
116
+ "Scopus, and has no record of having been."
117
+ ),
118
+ DISCONTINUED: (
119
+ "Scopus has discontinued this title β€” it was removed from the index "
120
+ "rather than ceasing publication. Articles published here will not "
121
+ "be indexed in Scopus. Scopus does not publish the reason for an "
122
+ "individual discontinuation; grounds range from publication-ethics "
123
+ "concerns to metrics such as self-citation, and discontinued titles "
124
+ "include some from major publishers. Verify independently before "
125
+ "submitting, and do not assume the journal is predatory."
126
+ ),
127
+ RENAMED: (
128
+ "This title is no longer indexed under this name, but the journal "
129
+ "continues. This is a rename or merger, not a quality concern."
130
+ ),
131
+ CEASED: (
132
+ "Scopus no longer indexes this title and no successor is recorded β€” "
133
+ "the journal appears to have ceased publication. Not flagged as a "
134
+ "discontinuation for cause."
135
+ ),
136
+ }
137
+
138
+
139
+ _ISSN_RE = re.compile(r"[^0-9X]")
140
+
141
+
142
+ def norm_issn(v: str | None) -> str | None:
143
+ if not v:
144
+ return None
145
+ s = _ISSN_RE.sub("", str(v).strip().upper())
146
+ if len(s) < 8:
147
+ s = s.zfill(8)
148
+ return s if len(s) == 8 else None
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # Records
153
+ # ---------------------------------------------------------------------------
154
+ @dataclass
155
+ class JournalRecord:
156
+ scopus_id: str
157
+ title: str
158
+ issn_print: str | None
159
+ issn_e: str | None
160
+ source_type: str | None
161
+ publisher: str | None
162
+ publisher_group: str | None
163
+ coverage_start: int | None
164
+ coverage_end: int | None
165
+ coverage_has_gap: bool
166
+ oa_unpaywall: bool
167
+ medline: bool
168
+ has_english: bool
169
+ languages: str | None
170
+ sjr: float | None
171
+ sjr_rank: int | None
172
+ sjr_best_quartile: str | None
173
+ h_index: int | None
174
+ cites_per_doc_2y: float | None
175
+ total_docs_year: int | None
176
+ oa_diamond: bool
177
+ overton: int | None
178
+ country: str | None
179
+ lifecycle: str = "active"
180
+ successor_title: str | None = None
181
+ successor_scopus_id: str | None = None
182
+ successor_is_active: bool = False
183
+ subjects: list[dict] = field(default_factory=list)
184
+ state: str = IndexationState.NOT_INDEXED
185
+ state_message: str = ""
186
+
187
+ @property
188
+ def warn(self) -> bool:
189
+ """True when a prospective author should be actively cautioned."""
190
+ return self.state in IndexationState.WARN
191
+
192
+ @property
193
+ def currently_indexed(self) -> bool:
194
+ return self.state not in IndexationState.NOT_CURRENTLY_INDEXED
195
+
196
+ def quartile_for(self, asjc: str) -> str | None:
197
+ """Quartile in one specific subject area, not the journal's best."""
198
+ for s in self.subjects:
199
+ if s["asjc"] == asjc:
200
+ return s.get("quartile")
201
+ return None
202
+
203
+ def to_dict(self) -> dict:
204
+ return asdict(self)
205
+
206
+
207
+ @dataclass
208
+ class Recommendation:
209
+ journal: JournalRecord
210
+ score: float
211
+ reasons: list[str]
212
+ method: str
213
+
214
+
215
+ # ---------------------------------------------------------------------------
216
+ # Index
217
+ # ---------------------------------------------------------------------------
218
+ _STOP = set("""
219
+ a an the and or but if of in on at to for with by from as is are was were be been
220
+ being this that these those it its we our they their he she his her you your not
221
+ no can may might will would should could have has had do does did than then so
222
+ such other others new novel study studies paper article research results method
223
+ methods approach based using used use show shows shown propose proposed present
224
+ presented provide provides between among during through both each more most many
225
+ much also however therefore thus while when where which who whom what how why
226
+ """.split())
227
+
228
+
229
+ class JournalIndex:
230
+ """Thread-safe read-only wrapper over journals.db."""
231
+
232
+ def __init__(self, db_path: str | Path):
233
+ self.db_path = str(db_path)
234
+ if not Path(self.db_path).exists():
235
+ raise FileNotFoundError(self.db_path)
236
+ self._local = threading.local()
237
+
238
+ @property
239
+ def con(self) -> sqlite3.Connection:
240
+ con = getattr(self._local, "con", None)
241
+ if con is None:
242
+ con = sqlite3.connect(f"file:{self.db_path}?mode=ro", uri=True,
243
+ check_same_thread=False)
244
+ con.row_factory = sqlite3.Row
245
+ con.execute("PRAGMA mmap_size = 268435456")
246
+ self._local.con = con
247
+ return con
248
+
249
+ # -- meta ---------------------------------------------------------------
250
+ def meta(self) -> dict[str, str]:
251
+ return {r["key"]: r["value"] for r in self.con.execute("SELECT * FROM meta")}
252
+
253
+ # -- lookup -------------------------------------------------------------
254
+ def lookup(self, issn: str | None = None, title: str | None = None,
255
+ scopus_id: str | None = None) -> JournalRecord | None:
256
+ row = None
257
+ if scopus_id:
258
+ row = self.con.execute(
259
+ "SELECT * FROM journals WHERE scopus_id = ?", (scopus_id,)
260
+ ).fetchone()
261
+ if row is None and issn:
262
+ key = norm_issn(issn)
263
+ if key:
264
+ row = self.con.execute(
265
+ "SELECT * FROM journals WHERE issn_e = ? OR issn_print = ? LIMIT 1",
266
+ (key, key),
267
+ ).fetchone()
268
+ if row is None and title:
269
+ row = self.con.execute(
270
+ "SELECT * FROM journals WHERE lower(title) = lower(?) LIMIT 1",
271
+ (title.strip(),),
272
+ ).fetchone()
273
+ if row is None:
274
+ hits = self._fts(title, limit=1, weights=self._W_LOOKUP)
275
+ if hits:
276
+ row = self.con.execute(
277
+ "SELECT * FROM journals WHERE scopus_id = ?", (hits[0][0],)
278
+ ).fetchone()
279
+ return self._to_record(row) if row else None
280
+
281
+ def _to_record(self, row: sqlite3.Row) -> JournalRecord:
282
+ subs = [
283
+ {"asjc": r["asjc"], "name": r["name"], "parent": r["parent_name"],
284
+ "quartile": r["quartile"], "is_primary": bool(r["is_primary"])}
285
+ for r in self.con.execute(
286
+ """SELECT ja.asjc, ja.is_primary, ja.quartile, a.name, a.parent_name
287
+ FROM journal_asjc ja JOIN asjc a ON a.code = ja.asjc
288
+ WHERE ja.scopus_id = ? ORDER BY ja.is_primary DESC, ja.asjc""",
289
+ (row["scopus_id"],),
290
+ )
291
+ ]
292
+ rec = JournalRecord(
293
+ scopus_id=row["scopus_id"], title=row["title"],
294
+ issn_print=self._fmt(row["issn_print"]), issn_e=self._fmt(row["issn_e"]),
295
+ source_type=row["source_type"], publisher=row["publisher"],
296
+ publisher_group=row["publisher_group"],
297
+ coverage_start=row["coverage_start"], coverage_end=row["coverage_end"],
298
+ coverage_has_gap=bool(row["coverage_has_gap"]),
299
+ oa_unpaywall=bool(row["oa_unpaywall"]), medline=bool(row["medline"]),
300
+ has_english=bool(row["has_english"]), languages=row["languages"],
301
+ sjr=row["sjr"], sjr_rank=row["sjr_rank"],
302
+ sjr_best_quartile=row["sjr_best_quartile"], h_index=row["h_index"],
303
+ cites_per_doc_2y=row["cites_per_doc_2y"],
304
+ total_docs_year=row["total_docs_year"],
305
+ oa_diamond=bool(row["oa_diamond"]), overton=row["overton"],
306
+ country=row["country"], lifecycle=row["lifecycle"],
307
+ successor_title=row["successor_title"],
308
+ successor_scopus_id=row["successor_scopus_id"],
309
+ successor_is_active=bool(row["successor_is_active"]),
310
+ subjects=subs,
311
+ )
312
+ rec.state = self._state(row)
313
+ rec.state_message = self._message(rec)
314
+ return rec
315
+
316
+ @staticmethod
317
+ def _message(rec: "JournalRecord") -> str:
318
+ """Base message, extended with the successor when we resolved one."""
319
+ msg = IndexationState.MESSAGES[rec.state]
320
+ if rec.successor_title:
321
+ where = (
322
+ "which is actively indexed"
323
+ if rec.successor_is_active
324
+ else "which is itself no longer indexed"
325
+ )
326
+ if rec.state == IndexationState.RENAMED:
327
+ msg += f" It continues as {rec.successor_title!r}, {where}."
328
+ elif rec.state in (IndexationState.DISCONTINUED, IndexationState.CEASED):
329
+ # A successor on a discontinued title does not soften the
330
+ # warning β€” it just tells the researcher where to look next.
331
+ msg += f" Scopus records a successor title, {rec.successor_title!r}, {where}."
332
+ return msg
333
+
334
+ @staticmethod
335
+ def _fmt(s: str | None) -> str | None:
336
+ return f"{s[:4]}-{s[4:]}" if s and len(s) == 8 else None
337
+
338
+ _STATE_BY_STATUS = {
339
+ "matched": IndexationState.INDEXED_RANKED,
340
+ "no_sjr_new_title": IndexationState.INDEXED_NEW,
341
+ "no_sjr_listed_unranked": IndexationState.INDEXED_UNRANKED,
342
+ "no_sjr_unmatched": IndexationState.INDEXED_NO_SJR,
343
+ }
344
+
345
+ _STATE_BY_LIFECYCLE = {
346
+ "discontinued": IndexationState.DISCONTINUED,
347
+ "renamed": IndexationState.RENAMED,
348
+ "ceased": IndexationState.CEASED,
349
+ }
350
+
351
+ @classmethod
352
+ def _state(cls, row: sqlite3.Row) -> str:
353
+ # Lifecycle wins over SJR status. A discontinued title may still carry a
354
+ # quartile from when it was indexed, and reporting that quartile as the
355
+ # headline would bury the thing the researcher actually needs to know.
356
+ lc = cls._STATE_BY_LIFECYCLE.get(row["lifecycle"])
357
+ if lc:
358
+ return lc
359
+ return cls._STATE_BY_STATUS.get(
360
+ row["sjr_status"], IndexationState.INDEXED_NO_SJR
361
+ )
362
+
363
+ # -- candidate generation ----------------------------------------------
364
+ # bm25 column weights: (scopus_id, title, publisher_group, subjects).
365
+ # Publisher is weighted to zero for topical work β€” "University Library
366
+ # System" is not evidence that a cinema journal wants your library paper,
367
+ # and leaving it at 1.0 lets one publisher flood the candidate pool. It
368
+ # stays queryable for name lookups via _W_LOOKUP.
369
+ _W_TOPIC = (0.0, 4.0, 0.0, 3.0)
370
+ _W_LOOKUP = (0.0, 8.0, 1.0, 0.5)
371
+
372
+ def _fts(self, text: str, limit: int = 400,
373
+ asjc: Sequence[str] | None = None,
374
+ weights: tuple[float, ...] | None = None,
375
+ active_only: bool = False) -> list[tuple[str, float]]:
376
+ terms = self._terms(text)
377
+ if not terms:
378
+ return []
379
+ w = weights or self._W_TOPIC
380
+ query = " OR ".join(f'"{t}"' for t in terms)
381
+ sql = f"""
382
+ SELECT f.scopus_id AS sid,
383
+ bm25(journals_fts, {w[0]}, {w[1]}, {w[2]}, {w[3]}) AS rank
384
+ FROM journals_fts f WHERE journals_fts MATCH ?
385
+ """
386
+ params: list = [query]
387
+ if active_only:
388
+ # Applied inside candidate generation, not after: the index holds
389
+ # 16,838 inactive titles, and letting them consume the candidate
390
+ # pool would silently shrink the number of real options considered.
391
+ sql += " AND f.scopus_id IN (SELECT scopus_id FROM journals WHERE active = 1)"
392
+ if asjc:
393
+ marks = ",".join("?" * len(asjc))
394
+ sql += f""" AND f.scopus_id IN (
395
+ SELECT scopus_id FROM journal_asjc WHERE asjc IN ({marks}))"""
396
+ params += list(asjc)
397
+ sql += " ORDER BY rank LIMIT ?"
398
+ params.append(limit)
399
+ return [(r["sid"], r["rank"]) for r in self.con.execute(sql, params)]
400
+
401
+ @staticmethod
402
+ def _terms(text: str, max_terms: int = 24) -> list[str]:
403
+ """
404
+ FTS5 treats bare punctuation as syntax, so terms are stripped to word
405
+ characters and quoted. Frequency-ordered, stopword-filtered.
406
+ """
407
+ from collections import Counter
408
+ words = re.findall(r"[A-Za-z][A-Za-z\-]{2,}", (text or "").lower())
409
+ words = [w.strip("-") for w in words if w not in _STOP and len(w) > 3]
410
+ return [w for w, _ in Counter(words).most_common(max_terms)]
411
+
412
+ # -- recommend ----------------------------------------------------------
413
+ def recommend(
414
+ self,
415
+ title: str = "",
416
+ abstract: str = "",
417
+ asjc: Sequence[str] | None = None,
418
+ *,
419
+ limit: int = 20,
420
+ candidate_pool: int = 400,
421
+ require_english: bool = True,
422
+ require_oa: bool = False,
423
+ exclude_book_series: bool = True,
424
+ min_quartile: str | None = None,
425
+ publisher_agreements: Iterable[str] = (),
426
+ embedder=None,
427
+ centroids=None,
428
+ asjc_classifier=None,
429
+ ) -> list[Recommendation]:
430
+ """
431
+ `asjc` β€” subject codes to hard-filter on. Supply these when you know the
432
+ manuscript's field; recall from free text alone is mediocre.
433
+ `min_quartile` β€” 'Q1' or 'Q2'. Applied against the quartile in the
434
+ filtered subject area when `asjc` is given, otherwise against
435
+ the journal's best quartile.
436
+ `publisher_agreements` β€” publisher_group names the institution holds a
437
+ read-and-publish deal with; matches get a scoring bonus and an
438
+ explicit reason string.
439
+ `embedder`/`centroids` β€” optional stage 2. See module docstring.
440
+ `asjc_classifier` β€” an AsjcClassifier. When `asjc` is not supplied, the
441
+ subject codes are inferred from the manuscript rather than
442
+ left empty. This matters more than any other option here: the
443
+ subject filter is what makes the results usable, and an
444
+ unfiltered free-text search returns unrelated journals.
445
+ """
446
+ inferred = None
447
+ if not asjc and asjc_classifier is not None:
448
+ inferred = asjc_classifier.classify(title=title, abstract=abstract)
449
+ asjc = inferred.codes or None
450
+
451
+ text = f"{title} {title} {abstract}".strip()
452
+ cands = self._fts(text, limit=candidate_pool, asjc=asjc, active_only=True)
453
+ if not cands and asjc:
454
+ marks = ",".join("?" * len(asjc))
455
+ cands = [
456
+ (r["scopus_id"], 0.0)
457
+ for r in self.con.execute(
458
+ f"""SELECT DISTINCT j.scopus_id FROM journals j
459
+ JOIN journal_asjc ja ON ja.scopus_id = j.scopus_id
460
+ WHERE ja.asjc IN ({marks}) AND j.active = 1
461
+ ORDER BY j.sjr DESC LIMIT ?""",
462
+ (*asjc, candidate_pool),
463
+ )
464
+ ]
465
+ if not cands:
466
+ return []
467
+
468
+ ranks = [r for _, r in cands]
469
+ lo, hi = min(ranks), max(ranks)
470
+ span = (hi - lo) or 1.0
471
+
472
+ method = "lexical+fit"
473
+ sem: dict[str, float] = {}
474
+ if embedder is not None and centroids is not None:
475
+ sem = self._semantic(text, [c for c, _ in cands], embedder, centroids)
476
+ if sem:
477
+ method = "lexical+semantic+fit"
478
+
479
+ out: list[Recommendation] = []
480
+ agreements = {p.lower() for p in publisher_agreements}
481
+ for sid, rank in cands:
482
+ row = self.con.execute(
483
+ "SELECT * FROM journals WHERE scopus_id = ?", (sid,)
484
+ ).fetchone()
485
+ if row is None:
486
+ continue
487
+ if exclude_book_series and row["source_type"] != "Journal":
488
+ continue
489
+ if require_english and not row["has_english"]:
490
+ continue
491
+ if require_oa and not row["oa_unpaywall"]:
492
+ continue
493
+ # Never recommend somewhere Scopus no longer indexes β€” discontinued,
494
+ # renamed or ceased. Lookup still returns these (a researcher asking
495
+ # about a specific title needs the answer); recommendation must not.
496
+ if not row["active"]:
497
+ continue
498
+
499
+ rec = self._to_record(row)
500
+ q = rec.quartile_for(asjc[0]) if asjc else rec.sjr_best_quartile
501
+ q = q or rec.sjr_best_quartile
502
+ if min_quartile and (q is None or q > min_quartile):
503
+ continue
504
+
505
+ # bm25 returns lower-is-better; invert to 0..1
506
+ lex = 1.0 - ((rank - lo) / span)
507
+ score = 0.45 * lex
508
+ reasons: list[str] = []
509
+
510
+ if sem:
511
+ score = 0.30 * lex + 0.30 * sem.get(sid, 0.0)
512
+
513
+ qbonus = {"Q1": 0.25, "Q2": 0.18, "Q3": 0.10, "Q4": 0.04}.get(q or "", 0.0)
514
+ score += qbonus
515
+ if q:
516
+ label = f"{q} in {self._asjc_name(asjc[0])}" if asjc else f"{q} (best quartile)"
517
+ reasons.append(label)
518
+ elif rec.state == IndexationState.INDEXED_NEW:
519
+ score += 0.06
520
+ reasons.append("Newly indexed β€” no quartile yet, not a quality signal")
521
+
522
+ if rec.coverage_end and rec.coverage_end >= 2026:
523
+ score += 0.05
524
+ else:
525
+ reasons.append(f"Coverage appears to end {rec.coverage_end} β€” verify status")
526
+
527
+ if rec.oa_diamond:
528
+ # Diamond = no APC and no subscription. For an author without
529
+ # grant funding this outranks quartile as a practical criterion,
530
+ # so it is weighted above the generic OA flag.
531
+ score += 0.10
532
+ reasons.append("Diamond open access β€” no APC")
533
+ elif rec.oa_unpaywall:
534
+ score += 0.06
535
+ reasons.append("Open access")
536
+
537
+ if rec.publisher_group and rec.publisher_group.lower() in agreements:
538
+ score += 0.12
539
+ reasons.append(f"Covered by your {rec.publisher_group} agreement β€” APC likely waived")
540
+
541
+ if rec.coverage_has_gap:
542
+ score -= 0.04
543
+ reasons.append("Discontinuous Scopus coverage")
544
+
545
+ if rec.medline:
546
+ reasons.append("Medline-sourced")
547
+
548
+ out.append(Recommendation(rec, round(score, 4), reasons, method))
549
+
550
+ out.sort(key=lambda r: r.score, reverse=True)
551
+ out = out[:limit]
552
+
553
+ # An inferred subject filter silently determines the entire result set,
554
+ # so it is never left implicit: every result says which subject was
555
+ # inferred and how, giving the user something concrete to correct when
556
+ # the classifier picked the wrong field.
557
+ if inferred is not None:
558
+ note = f"Subject inferred as {', '.join(inferred.names)} ({inferred.method})"
559
+ for r in out:
560
+ r.reasons.append(note)
561
+ r.method = f"{r.method}+asjc:{inferred.method}"
562
+ return out
563
+
564
+ def _asjc_name(self, code: str) -> str:
565
+ r = self.con.execute("SELECT name, parent_name FROM asjc WHERE code = ?",
566
+ (code,)).fetchone()
567
+ return (r["name"] or r["parent_name"]) if r else code
568
+
569
+ @staticmethod
570
+ def _semantic(text, sids, embedder, centroids) -> dict[str, float]:
571
+ import numpy as np
572
+ v = np.asarray(embedder(text), dtype="float32").ravel()
573
+ v /= (np.linalg.norm(v) or 1.0)
574
+ got = {s: centroids[s] for s in sids if s in centroids}
575
+ if not got:
576
+ return {}
577
+ keys = list(got)
578
+ M = np.vstack([got[k] for k in keys]).astype("float32")
579
+ M /= (np.linalg.norm(M, axis=1, keepdims=True) + 1e-9)
580
+ sims = M @ v
581
+ lo, hi = float(sims.min()), float(sims.max())
582
+ span = (hi - lo) or 1.0
583
+ return {k: float((s - lo) / span) for k, s in zip(keys, sims)}
584
+
585
+ # -- subject helpers ----------------------------------------------------
586
+ def find_asjc(self, query: str, limit: int = 10) -> list[dict]:
587
+ """Resolve a free-text subject to ASJC codes, for populating the filter."""
588
+ like = f"%{query.strip().lower()}%"
589
+ return [
590
+ dict(r) for r in self.con.execute(
591
+ """SELECT code, name, parent_code, parent_name, n_journals FROM asjc
592
+ WHERE lower(name) LIKE ? OR lower(parent_name) LIKE ?
593
+ ORDER BY n_journals DESC LIMIT ?""",
594
+ (like, like, limit),
595
+ )
596
+ ]
597
+
598
+ def publisher_titles(self, publisher_group: str) -> int:
599
+ return self.con.execute(
600
+ "SELECT count(*) FROM journals WHERE publisher_group = ?",
601
+ (publisher_group,),
602
+ ).fetchone()[0]
train_asjc.py ADDED
@@ -0,0 +1,329 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Train the abstract -> ASJC multi-label classifier.
3
+
4
+ # interim model, no network needed β€” trains on journal titles in the index
5
+ python train_asjc.py --corpus titles --db journals.db --out asjc_model.joblib
6
+
7
+ # full model, after harvest_openalex.py has produced labelled abstracts
8
+ python train_asjc.py --corpus abstracts --train-jsonl asjc_train.jsonl \
9
+ --db journals.db --out asjc_model.joblib
10
+
11
+ Why TF-IDF + linear and not a transformer
12
+ -----------------------------------------
13
+ The task is topical multi-label text classification over 334 labels with a large
14
+ vocabulary signal β€” exactly where a linear model over character and word n-grams
15
+ is hard to beat per unit of cost. It trains in seconds on CPU, the artifact is
16
+ tens of MB, and inference is sub-millisecond with no GPU and no model download at
17
+ Space startup. A fine-tuned transformer might add a few points of F1 for ~400MB
18
+ and a cold-start penalty on every Space restart. Take the linear model; spend the
19
+ complexity budget on the LLM fallback for the cases it actually gets wrong.
20
+
21
+ Two corpora, deliberately
22
+ -------------------------
23
+ `titles` trains on journal titles (48,888 labelled examples, already in the
24
+ index, zero network). Ships immediately. But it trains on titles and
25
+ is applied to abstracts, and that domain gap is real β€” treat its
26
+ numbers as an upper bound on what it will do in production.
27
+
28
+ `abstracts` trains on article abstracts labelled with their journal's ASJC codes
29
+ (weak supervision). Far better matched to inference, and the only
30
+ version whose held-out score means what it appears to mean.
31
+
32
+ Label noise in the `abstracts` corpus
33
+ -------------------------------------
34
+ Giving every article its journal's codes is weak supervision: a physics paper in
35
+ a multidisciplinary journal inherits 'Medicine' too. `--max-labels` drops journals
36
+ carrying more than N ASJC codes from training, which removes the worst offenders
37
+ (megajournals and general-science titles) at the cost of some coverage. Their
38
+ articles are still *classifiable* at inference β€” they are just poor teachers.
39
+ """
40
+ from __future__ import annotations
41
+
42
+ import argparse
43
+ import json
44
+ import re
45
+ import sqlite3
46
+ import sys
47
+ from pathlib import Path
48
+
49
+ import numpy as np
50
+ from scipy import sparse
51
+ from sklearn.feature_extraction.text import TfidfVectorizer
52
+ from sklearn.linear_model import SGDClassifier
53
+ from sklearn.model_selection import train_test_split
54
+ from sklearn.multiclass import OneVsRestClassifier
55
+ from sklearn.preprocessing import MultiLabelBinarizer
56
+
57
+ import joblib
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Corpora
62
+ # ---------------------------------------------------------------------------
63
+ def load_titles(db: str, max_labels: int) -> tuple[list[str], list[list[str]]]:
64
+ """Journal title + subject names -> its ASJC codes."""
65
+ con = sqlite3.connect(db)
66
+ con.row_factory = sqlite3.Row
67
+ rows = con.execute(
68
+ """SELECT j.scopus_id, j.title, j.publisher_group,
69
+ group_concat(ja.asjc) codes, count(*) n
70
+ FROM journals j JOIN journal_asjc ja ON ja.scopus_id = j.scopus_id
71
+ WHERE j.title IS NOT NULL
72
+ GROUP BY j.scopus_id"""
73
+ ).fetchall()
74
+ X, y = [], []
75
+ for r in rows:
76
+ if r["n"] > max_labels:
77
+ continue
78
+ X.append(r["title"])
79
+ y.append(r["codes"].split(","))
80
+ con.close()
81
+ return X, y
82
+
83
+
84
+ def load_abstracts(path: str, db: str, max_labels: int) -> tuple[list[str], list[list[str]]]:
85
+ """
86
+ JSONL from harvest_openalex.py: {"text": ..., "scopus_id": ...}.
87
+ Labels come from the index, so the harvest never has to carry them.
88
+ """
89
+ con = sqlite3.connect(db)
90
+ codes: dict[str, list[str]] = {}
91
+ for sid, c in con.execute(
92
+ "SELECT scopus_id, group_concat(asjc) FROM journal_asjc GROUP BY scopus_id"
93
+ ):
94
+ codes[sid] = c.split(",")
95
+ con.close()
96
+
97
+ X, y = [], []
98
+ with open(path, encoding="utf-8") as f:
99
+ for line in f:
100
+ rec = json.loads(line)
101
+ c = codes.get(rec.get("scopus_id"))
102
+ if not c or len(c) > max_labels:
103
+ continue
104
+ t = (rec.get("text") or "").strip()
105
+ if len(t) < 120: # a bare title is not an abstract
106
+ continue
107
+ X.append(t)
108
+ y.append(c)
109
+ return X, y
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Evaluation
114
+ # ---------------------------------------------------------------------------
115
+ def evaluate(clf, Xv, Y_true, classes: np.ndarray, parent_of: dict[str, str]) -> dict:
116
+ """
117
+ Ranking metrics, not threshold metrics.
118
+
119
+ The classifier feeds a *filter*, and the filter takes the top few codes β€” so
120
+ what matters is whether a correct code is ranked highly, not whether some
121
+ probability cleared 0.5. Reporting only micro-F1 at a fixed threshold would
122
+ flatter a model that is well-calibrated but badly ordered, and vice versa.
123
+
124
+ Parent accuracy (2-digit ASJC) is reported alongside because confusing
125
+ 'Information Systems' with 'Computer Science Applications' barely harms the
126
+ candidate set, while confusing Computer Science with Dentistry destroys it.
127
+ """
128
+ P = clf.predict_proba(Xv)
129
+ order = np.argsort(-P, axis=1)
130
+ res: dict[str, float] = {}
131
+
132
+ for k in (1, 3, 5):
133
+ topk = order[:, :k]
134
+ hit = sum(
135
+ bool(set(classes[topk[i]]) & set(Y_true[i])) for i in range(len(Y_true))
136
+ )
137
+ res[f"top{k}_hit"] = hit / len(Y_true)
138
+
139
+ # Recall of the full true label set within the top 5 predictions.
140
+ top5 = order[:, :5]
141
+ res["top5_label_recall"] = float(np.mean([
142
+ len(set(classes[top5[i]]) & set(Y_true[i])) / len(Y_true[i])
143
+ for i in range(len(Y_true))
144
+ ]))
145
+
146
+ top1_parent = [parent_of.get(classes[order[i, 0]]) for i in range(len(Y_true))]
147
+ res["top1_parent_hit"] = float(np.mean([
148
+ top1_parent[i] in {parent_of.get(c) for c in Y_true[i]}
149
+ for i in range(len(Y_true))
150
+ ]))
151
+ return res
152
+
153
+
154
+ def main() -> None:
155
+ ap = argparse.ArgumentParser()
156
+ ap.add_argument("--corpus", choices=["titles", "abstracts"], default="titles")
157
+ ap.add_argument("--db", default="journals.db")
158
+ ap.add_argument("--train-jsonl", default="asjc_train.jsonl")
159
+ ap.add_argument("--out", default="asjc_model.joblib")
160
+ ap.add_argument("--max-labels", type=int, default=6,
161
+ help="drop journals carrying more ASJC codes than this from training")
162
+ ap.add_argument("--min-per-label", type=int, default=8,
163
+ help="drop labels with fewer training examples than this")
164
+ ap.add_argument("--eval-jsonl", default="asjc_eval.jsonl")
165
+ ap.add_argument("--max-features", type=int, default=120_000)
166
+ ap.add_argument("--n-jobs", type=int, default=2,
167
+ help="parallel per-label fits. 334 estimators over a large sparse "
168
+ "matrix can exhaust memory at -1; 4 is safe on a 16GB Space.")
169
+ args = ap.parse_args()
170
+
171
+ if args.corpus == "titles":
172
+ X, y = load_titles(args.db, args.max_labels)
173
+ else:
174
+ if not Path(args.train_jsonl).exists():
175
+ sys.exit(f"{args.train_jsonl} not found β€” run harvest_openalex.py first")
176
+ X, y = load_abstracts(args.train_jsonl, args.db, args.max_labels)
177
+
178
+ print(f"corpus={args.corpus} examples={len(X)} "
179
+ f"mean labels/example={np.mean([len(c) for c in y]):.2f}")
180
+
181
+ # Drop labels too rare to learn. They stay resolvable through the LLM
182
+ # fallback, which does not need training examples.
183
+ from collections import Counter
184
+ freq = Counter(c for cs in y for c in cs)
185
+ keep = {c for c, n in freq.items() if n >= args.min_per_label}
186
+ y = [[c for c in cs if c in keep] for cs in y]
187
+ mask = [i for i, cs in enumerate(y) if cs]
188
+ X, y = [X[i] for i in mask], [y[i] for i in mask]
189
+ print(f"labels kept: {len(keep)} of {len(freq)} examples after filtering: {len(X)}")
190
+
191
+ mlb = MultiLabelBinarizer()
192
+ Y = mlb.fit_transform(y)
193
+
194
+ Xtr, Xte, Ytr, Yte, ytr_raw, yte_raw = train_test_split(
195
+ X, Y, y, test_size=0.2, random_state=42
196
+ )
197
+
198
+ # Word n-grams carry the topical signal; character n-grams cover
199
+ # morphology, non-English titles and the domain shift between titles and
200
+ # abstracts. Union of both, sublinear tf to damp long abstracts.
201
+ word = TfidfVectorizer(
202
+ sublinear_tf=True, strip_accents="unicode", lowercase=True,
203
+ ngram_range=(1, 2), min_df=2, max_features=args.max_features,
204
+ stop_words="english", dtype=np.float32,
205
+ )
206
+ char = TfidfVectorizer(
207
+ sublinear_tf=True, strip_accents="unicode", lowercase=True,
208
+ analyzer="char_wb", ngram_range=(3, 5), min_df=3,
209
+ max_features=args.max_features // 3, dtype=np.float32,
210
+ )
211
+ Xtr_v = sparse.hstack([word.fit_transform(Xtr), char.fit_transform(Xtr)]).tocsr()
212
+ Xte_v = sparse.hstack([word.transform(Xte), char.transform(Xte)]).tocsr()
213
+ print(f"features: {Xtr_v.shape[1]}")
214
+
215
+ # SGD with modified_huber rather than liblinear logistic regression.
216
+ # 334 one-vs-rest fits over ~450k sparse features takes liblinear many
217
+ # minutes and it trips joblib's read-only memmap under parallel fan-out;
218
+ # SGD fits the same problem in seconds, scales to the ~1M-abstract corpus
219
+ # the harvest will produce, and modified_huber still exposes predict_proba,
220
+ # which the LLM fallback needs to decide when to escalate.
221
+ # log_loss, not modified_huber. modified_huber's predict_proba clips the
222
+ # margin and normalises, so it returns a saturated 1.0/0.0 with nothing in
223
+ # between β€” fine for ranking, useless for deciding when to escalate to the
224
+ # LLM. log_loss gives genuine logistic probabilities that a threshold can
225
+ # act on.
226
+ #
227
+ # class_weight is left unbalanced on purpose. Balancing inflates scores for
228
+ # rare labels, which is right for per-label F1 and wrong here: it pushed
229
+ # 'Rehabilitation' to the top of a cardiology abstract. The output feeds a
230
+ # ranked filter, so ordering matters more than per-label parity.
231
+ clf = OneVsRestClassifier(
232
+ SGDClassifier(
233
+ loss="log_loss", alpha=1e-6, max_iter=30, tol=1e-4,
234
+ random_state=42,
235
+ ),
236
+ n_jobs=args.n_jobs,
237
+ )
238
+ with joblib.parallel_config(backend="loky", max_nbytes=None):
239
+ clf.fit(Xtr_v, Ytr)
240
+
241
+ con = sqlite3.connect(args.db)
242
+ parent_of = {c: p for c, p in con.execute("SELECT code, parent_code FROM asjc")}
243
+ names = {c: (n or pn) for c, n, pn in
244
+ con.execute("SELECT code, name, parent_name FROM asjc")}
245
+ con.close()
246
+
247
+ res = evaluate(clf, Xte_v, yte_raw, mlb.classes_, parent_of)
248
+ print(f"\nheld-out performance (same distribution as training: {args.corpus})")
249
+ for k, v in res.items():
250
+ print(f" {k:<20} {v:.3f}")
251
+
252
+ # The number that actually matters. When training on `titles`, the held-out
253
+ # score above is measured on 5-word journal titles while production input is
254
+ # a 200-word abstract β€” a different and much easier problem. This second
255
+ # evaluation runs on hand-labelled abstracts across 20 fields, which is the
256
+ # distribution the classifier is really deployed against.
257
+ if Path(args.eval_jsonl).exists():
258
+ ev = [json.loads(l) for l in open(args.eval_jsonl, encoding="utf-8")]
259
+ Xe = sparse.hstack([word.transform([e["text"] for e in ev]),
260
+ char.transform([e["text"] for e in ev])]).tocsr()
261
+ Pe = clf.predict_proba(Xe)
262
+ order = np.argsort(-Pe, axis=1)
263
+ # Two standards, reported separately and deliberately.
264
+ #
265
+ # LENIENT counts a hit when *any* acceptable code appears. It overstates
266
+ # usefulness: for a seismic-engineering abstract, 'Engineering
267
+ # (miscellaneous)' is in `accept` and scores a hit, but filtering on it
268
+ # returns nonlinear-analysis maths journals, while the preferred code
269
+ # 2205 returns concrete-structures journals. Same "hit", different
270
+ # recommendation quality.
271
+ #
272
+ # STRICT requires the single best code β€” accept[0], the one a subject
273
+ # librarian would choose. This is the number to watch when retraining on
274
+ # the abstract corpus, because it is the one that tracks whether the
275
+ # recommendations are actually good.
276
+ n = len(ev)
277
+ hits1 = hits3 = parent1 = 0
278
+ s5 = s3 = 0
279
+ print(f"\nabstract eval ({n} hand-labelled abstracts)")
280
+ for i, e in enumerate(ev):
281
+ top5 = [mlb.classes_[j] for j in order[i, :5]]
282
+ top3, top1 = top5[:3], top5[0]
283
+ pref = e["accept"][0]
284
+ ok1 = top1 in e["accept"]
285
+ ok3 = bool(set(top3) & set(e["accept"]))
286
+ okp = parent_of.get(top1) in {parent_of.get(c) for c in e["accept"]}
287
+ hits1 += ok1; hits3 += ok3; parent1 += okp
288
+ s5 += pref in top5
289
+ s3 += pref in top3
290
+ flag = "OK " if pref in top5 else ("weak" if ok3 else "MISS")
291
+ print(f" {flag} {e['field'][:28]:<28} pref {pref} "
292
+ f"{'in top5' if pref in top5 else 'ABSENT':<8} -> "
293
+ f"{', '.join(f'{c}' for c in top3)}")
294
+ print(f" lenient: top1 {hits1/n:.2f} top3 {hits3/n:.2f} parent {parent1/n:.2f}")
295
+ print(f" STRICT : preferred-in-top3 {s3/n:.2f} preferred-in-top5 {s5/n:.2f}")
296
+ print(" strict preferred-in-top5 is the number that gates shipping β€” it "
297
+ "bounds what the LLM stage can possibly pick.")
298
+ res.update({"abstract_top1": hits1 / n, "abstract_top3": hits3 / n,
299
+ "abstract_top1_parent": parent1 / n,
300
+ "abstract_preferred_top3": s3 / n,
301
+ "abstract_preferred_top5": s5 / n})
302
+
303
+ # Compact the artifact before saving. The bulk is not the vocabulary β€” it is
304
+ # 334 dense float64 coefficient vectors (334 x ~160k x 8 bytes β‰ˆ 427 MB
305
+ # before compression). Casting to float32 halves that with no measurable
306
+ # effect on ranking, and sparsify() drops the near-zero majority. This
307
+ # matters operationally: the Space pulls this file on every cold start.
308
+ for est in clf.estimators_:
309
+ est.coef_ = est.coef_.astype("float32")
310
+ est.sparsify()
311
+ joblib.dump(
312
+ {"word": word, "char": char, "clf": clf, "classes": mlb.classes_,
313
+ "names": names, "parent_of": parent_of, "corpus": args.corpus,
314
+ "metrics": res},
315
+ args.out, compress=3,
316
+ )
317
+ print(f"\nwrote {args.out} ({Path(args.out).stat().st_size/1e6:.1f} MB)")
318
+
319
+ # Sparsified coefficients change the predict path, so re-check the eval
320
+ # rather than assuming the compaction was lossless.
321
+ chk = joblib.load(args.out)
322
+ Xc = sparse.hstack([chk["word"].transform([X[0]]),
323
+ chk["char"].transform([X[0]])]).tocsr()
324
+ assert chk["clf"].predict_proba(Xc).shape[1] == len(mlb.classes_)
325
+ print("compacted model reloads and predicts correctly")
326
+
327
+
328
+ if __name__ == "__main__":
329
+ main()