Spaces:
Running
Running
Upload 6 files
Browse files- asjc_classifier.py +216 -0
- asjc_eval.jsonl +20 -0
- build_centroids.py +130 -0
- harvest_openalex.py +353 -0
- researchbee_journals.py +602 -0
- train_asjc.py +329 -0
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()
|