File size: 8,606 Bytes
1916d8e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
"""

Abstract -> ASJC subject codes, for populating ResearchBee's recommendation filter.



    from asjc_classifier import AsjcClassifier



    clf = AsjcClassifier("asjc_model.joblib")           # local model only

    clf = AsjcClassifier("asjc_model.joblib", llm=True) # + LLM adjudication



    result = clf.classify(title=..., abstract=...)

    result.codes        # ['3309', '1710'] β€” feed straight to recommend(asjc=...)

    result.method       # 'linear+llm' | 'linear-topk'

    result.candidates   # what the LLM was asked to choose between



Architecture, and why it is not the obvious one

-----------------------------------------------

The obvious design is "run the linear model, escalate to an LLM when confidence

is low". Measured on the held-out abstract set, that design cannot work: the

top-1 probability averages 0.367 when the prediction is correct and 0.330 when

it is wrong. There is no threshold separating them β€” the model is wrong often

and unbothered about it.



What the model *is* good at is narrowing. A correct code appears within:



    top-1   30%

    top-3   70%

    top-5   95%     <- and flat thereafter



So it is used as a candidate generator, not a decider. The linear model cuts 334

codes to 5 with ~95% recall; the LLM picks among those 5. That inverts the usual

cost argument in our favour β€” the prompt carries five labelled options rather

than a 334-entry taxonomy, so the call is small, fast and hard to get wrong.



Degradation without an API key is graceful: `codes` becomes the top 3, which the

recommender applies as an OR filter. Recall is 70% rather than 95%, and the

candidate pool is broader, but nothing breaks.



Retrain after the OpenAlex harvest

----------------------------------

The shipped model is trained on journal titles, because that is the labelled text

available without a network. It is applied to abstracts. `harvest_openalex.py`

produces the matched corpus; retraining on it and re-running `asjc_eval.jsonl`

measures the gain on the same 20 abstracts, so the upgrade is quantified rather

than assumed.

"""
from __future__ import annotations

import json
import os
import re
from dataclasses import dataclass, field
from pathlib import Path

import joblib
import numpy as np
from scipy import sparse

__all__ = ["AsjcClassifier", "AsjcResult"]

#: Where the linear model's recall saturates. Measured, not guessed.
DEFAULT_K = 5
#: Codes returned when no LLM is available. top-3 = 70% recall.
FALLBACK_K = 3


@dataclass
class AsjcResult:
    codes: list[str]
    names: list[str]
    method: str
    candidates: list[dict] = field(default_factory=list)
    llm_error: str | None = None

    def __bool__(self) -> bool:
        return bool(self.codes)


_PROMPT = """You are classifying an academic manuscript into Scopus ASJC subject areas.



A statistical model has narrowed 334 possible subject areas to the {n} candidates \

below. One or more of them is correct.



Candidates:

{candidates}



Manuscript title: {title}



Abstract: {abstract}



Choose the 1-3 candidates that best describe where this manuscript belongs, most \

appropriate first. Choose only from the candidate list β€” do not invent codes. If \

the manuscript is genuinely interdisciplinary, return more than one; if it clearly \

belongs to a single area, return just that one.



Respond with JSON only, no other text:

{{"codes": ["NNNN", ...], "reason": "one short sentence"}}"""


class AsjcClassifier:
    def __init__(

        self,

        model_path: str | Path = "asjc_model.joblib",

        llm: bool = False,

        llm_model: str | None = None,

        api_key: str | None = None,

    ):
        self.bundle = joblib.load(model_path)
        self.classes: np.ndarray = self.bundle["classes"]
        self.names: dict[str, str] = self.bundle["names"]
        self.parent_of: dict[str, str] = self.bundle["parent_of"]
        self.trained_on: str = self.bundle.get("corpus", "unknown")

        self._client = None
        # Model id is configuration, not a constant β€” set ASJC_LLM_MODEL to
        # whichever model you are entitled to. Verify it against your account's
        # available models rather than trusting a default baked in here.
        self.llm_model = llm_model or os.getenv("ASJC_LLM_MODEL", "claude-sonnet-4-5")
        if llm:
            key = api_key or os.getenv("ANTHROPIC_API_KEY")
            if key:
                try:
                    import anthropic
                    self._client = anthropic.Anthropic(api_key=key)
                except ImportError:
                    pass   # falls back to top-k; reported in AsjcResult.method

    # -- stage 1: narrow ----------------------------------------------------
    def candidates(self, text: str, k: int = DEFAULT_K) -> list[dict]:
        """The linear model's top-k, with names attached. ~95% recall at k=5."""
        X = sparse.hstack([
            self.bundle["word"].transform([text]),
            self.bundle["char"].transform([text]),
        ]).tocsr()
        p = self.bundle["clf"].predict_proba(X)[0]
        order = np.argsort(-p)[:k]
        return [
            {
                "code": str(self.classes[i]),
                "name": self.names.get(str(self.classes[i]), str(self.classes[i])),
                "score": round(float(p[i]), 4),
            }
            for i in order
        ]

    # -- stage 2: adjudicate ------------------------------------------------
    def classify(

        self,

        title: str = "",

        abstract: str = "",

        k: int = DEFAULT_K,

        max_codes: int = 3,

    ) -> AsjcResult:
        text = f"{title} {title} {abstract}".strip()
        if not text:
            return AsjcResult([], [], "empty")

        cands = self.candidates(text, k=k)
        if not cands:
            return AsjcResult([], [], "no-candidates")

        if self._client is None:
            picked = [c["code"] for c in cands[:FALLBACK_K]]
            return AsjcResult(picked, [self.names.get(c, c) for c in picked],
                              "linear-topk", cands)

        listing = "\n".join(f"  {c['code']}  {c['name']}" for c in cands)
        prompt = _PROMPT.format(
            n=len(cands), candidates=listing,
            title=title or "(not supplied)",
            abstract=(abstract or "(not supplied)")[:4000],
        )
        try:
            msg = self._client.messages.create(
                model=self.llm_model,
                max_tokens=300,
                temperature=0,
                messages=[{"role": "user", "content": prompt}],
            )
            raw = "".join(b.text for b in msg.content if getattr(b, "type", "") == "text")
            picked = self._parse(raw, {c["code"] for c in cands})[:max_codes]
            if picked:
                return AsjcResult(picked, [self.names.get(c, c) for c in picked],
                                  "linear+llm", cands)
            err = "LLM returned no valid code from the candidate set"
        except Exception as e:                      # noqa: BLE001
            err = f"{type(e).__name__}: {e}"

        # Any LLM failure degrades to the statistical answer rather than raising.
        # A broader-than-ideal subject filter is a far better outcome for the
        # caller than a recommendation request that errors out.
        picked = [c["code"] for c in cands[:FALLBACK_K]]
        return AsjcResult(picked, [self.names.get(c, c) for c in picked],
                          "linear-topk", cands, llm_error=err)

    @staticmethod
    def _parse(raw: str, allowed: set[str]) -> list[str]:
        """

        Pull codes out of the reply and hard-filter to the candidate set.



        The filter is the point: it makes an invented or hallucinated code

        structurally impossible to return, so the worst case is fewer codes

        rather than a wrong one that silently poisons the journal filter.

        """
        codes: list[str] = []
        m = re.search(r"\{.*\}", raw, re.S)
        if m:
            try:
                codes = [str(c).strip() for c in json.loads(m.group(0)).get("codes", [])]
            except (json.JSONDecodeError, AttributeError, TypeError):
                codes = []
        if not codes:
            codes = re.findall(r"\b\d{4}\b", raw)
        out: list[str] = []
        for c in codes:
            if c in allowed and c not in out:
                out.append(c)
        return out