File size: 11,474 Bytes
6d90fd6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Scientific-paper classification, public v2 format: LIST FIRST for choices; paper first for yes/no.
Apache-2.0. No generated text, no remote model code. The Transformers backend does not reuse prefix states;
use vllm_fast.FastPaperClassifier for high-throughput shared-taxonomy batches.
"""
from __future__ import annotations
import json
import random
from pathlib import Path

SYSTEM = ("You classify scientific papers. You get a paper (its title, and its abstract when available), then a "
          "question. For a list of options, answer with the code of the option that best describes the research "
          "content of the paper, following the classification's own meaning of each option. For a single topic, answer "
          "yes if the topic is a central subject of the paper, else no. On topic: the paper studies the topic itself as "
          "a main subject: the phenomenon, its causes, consequences, mechanisms, measurement, methods, or how it is "
          "managed, in any system or organism. Not on topic: the topic is absent or only mentioned in passing; or the "
          "words mean something else in this paper; or the paper studies a neighbouring subject without studying the "
          "topic itself.")
NONE = "None of these (outside the options listed)"
QUESTION = "Which option best describes the research content of this paper?"
SCHEME = "Classification of research"


def paper_text(title, abstract=None, venue=None, year=None):
    """Benchmark paper representation: title, optional venue/year, abstract limited to 1,500 characters.
    Title-only inputs are valid. A complete paper is not required or read by this model."""
    s = f"Title: {title.strip()}"
    if venue:
        s += f"\nVenue: {venue.strip()}"
    if year:
        s += f"\nYear: {year}"
    abstract = " ".join((abstract or "").split())
    return s + (f"\nAbstract: {abstract[:1500]}" if abstract else "\n(no abstract)")


def messages(user):
    return [{"role": "system", "content": SYSTEM}, {"role": "user", "content": user}]


def option_line(code, o, describe=True, parents=True):
    extra = [x for x in ((o.get("about") if describe else None),
                         (f"in {o['parent']}" if parents and o.get("parent") else None)) if x]
    return f"{code}. {o['name']}" + (" — " + "; ".join(extra) if extra else "")


def head_text(scheme, question, options, codes, describe=True, parents=True):
    lines = "\n".join(option_line(c, o, describe, parents) for c, o in zip(codes, options))
    return f"## Classification: {scheme}\n{question}\nOptions:\n{lines}\n"


def tail_text(paper):
    return f"\n## Paper\n{paper}\n\nAnswer with the option code only."


def yesno_prompt(paper, topic, definition=None):
    d = f" (what it covers: {definition})" if definition else ""
    return f"{paper}\n\n## Topic: {topic}{d}\nIs this topic a central subject of the paper? Answer yes or no."


def read_config(model, revision=None):
    p = Path(model) / "classifier.json"
    if not p.exists():
        from huggingface_hub import hf_hub_download
        p = Path(hf_hub_download(model, "classifier.json", revision=revision))
    cfg = json.loads(p.read_text())
    if cfg.get("format") != "list-first-filler-v1":
        raise ValueError("This helper is for the v2/list-first release, not the earlier paper-first models.")
    return cfg


class PaperInputs:
    """Exact token construction. No truncation of taxonomy/options or the answer prefix.
    Long paper text alone is capped at paper_tokens. Oversized prompts raise ValueError instead of dropping labels.
    The prefix is tokenized once and padded with individual newline token IDs to a cache-block boundary.
    Do not concatenate these newlines as a string and retokenize: token merging changes the prompt."""
    def __init__(self, tokenizer, config, block=None, paper_tokens=700, max_tokens=4000):
        self.tok, self.cfg = tokenizer, config
        self.block = int(block or config.get("block", 544))
        self.paper_tokens, self.max_tokens = paper_tokens, max_tokens
        if self.block < 1 or paper_tokens < 1 or max_tokens > 4096:
            raise ValueError("block/paper_tokens must be positive; this helper supports max_tokens <= 4096")
        probe = tokenizer.apply_chat_template(messages("XXUSERXX"), tokenize=False,
                    add_generation_prompt=True, enable_thinking=False)
        self.pre, self.post = probe.split("XXUSERXX")
        filler = self.encode("\n")
        if len(filler) != 1 or filler[0] != config["filler"]:
            raise ValueError("Tokenizer/newline token differs from the release.")
        self.filler = filler[0]
        for code, token in zip(config["codes"], config["code_ids"]):
            if self.encode(code) != [token]:
                raise ValueError(f"Wrong tokenizer for answer code {code}")
        for word in ("yes", "no"):
            if self.encode(word) != [config[word]]:
                raise ValueError(f"Wrong tokenizer for {word}")

    def encode(self, text):
        return self.tok.encode(text, add_special_tokens=False)

    def normalize_options(self, options, none_option=False):
        out = []
        for x in options:
            o = {"name": x} if isinstance(x, str) else dict(x)
            if not isinstance(o.get("name"), str) or not o["name"].strip():
                raise ValueError("Every option needs a nonempty name")
            o["id"] = o.get("id", o["name"])
            if not isinstance(o["id"], str):
                raise ValueError("Option IDs must be strings")
            out.append(o)
        if none_option:
            out.append({"name": NONE, "id": "__none__"})
        if not 2 <= len(out) <= len(self.cfg["codes"]):
            raise ValueError(f"Supply 2–{len(self.cfg['codes'])} options; larger taxonomies require multiple questions")
        if len({o["id"] for o in out}) != len(out):
            raise ValueError("Option names/IDs must be unique; give each duplicate name a distinct 'id'")
        return out

    def prefix(self, opts, scheme, question, describe=True, parents=True):
        codes = self.cfg["codes"][:len(opts)]
        h = self.encode(self.pre + head_text(scheme, question, opts, codes, describe, parents))
        return h + [self.filler] * ((-len(h)) % self.block)

    def pick(self, paper, prefix):
        p = self.encode(paper)
        if len(p) > self.paper_tokens:
            paper = self.tok.decode(p[:self.paper_tokens])
        ids = prefix + self.encode(tail_text(paper) + self.post)
        return self.check_length(ids)

    def yesno(self, paper, topic, definition=None):
        p = self.encode(paper)
        if len(p) > self.paper_tokens:
            paper = self.tok.decode(p[:self.paper_tokens])
        text = self.tok.apply_chat_template(messages(yesno_prompt(paper, topic, definition)), tokenize=False,
                                            add_generation_prompt=True, enable_thinking=False)
        return self.check_length(self.encode(text))

    def check_length(self, ids):
        if len(ids) > self.max_tokens:
            raise ValueError(f"Prompt has {len(ids)} tokens; limit {self.max_tokens}. Shorten descriptions/labels, "
                             "reduce paper_tokens, or split the taxonomy. No options were silently truncated.")
        return ids


class ClassifierTasks:
    """Shared task API for the Transformers and vLLM backends."""
    def classify_many(self, papers, options, scheme=SCHEME, question=QUESTION, *,
                      describe=True, parents=True, none_option=False, orders=1, seed=0):
        papers = list(papers)
        if not papers:
            return []
        if not isinstance(orders, int) or orders < 1:
            raise ValueError("orders must be a positive integer")
        opts = self.inputs.normalize_options(options, none_option)
        rng = random.Random(seed);k = len(opts)
        total = [[0.0] * k for _ in papers]
        for order in range(orders):
            perm = list(range(k)) if order == 0 else rng.sample(range(k), k)
            prefix = self.inputs.prefix([opts[j] for j in perm], scheme, question, describe, parents)
            rows = [self.inputs.pick(p, prefix) for p in papers]
            probs = self._scores(rows, [self.cfg["code_ids"][:k]] * len(rows), shared_prefix=True)
            for t, p in zip(total, probs):
                for pos, j in enumerate(perm):
                    t[j] += p[pos] / orders
        return [dict(sorted(((o["id"], p) for o, p in zip(opts, vals)), key=lambda x: -x[1])) for vals in total]

    def classify(self, paper, options, scheme=SCHEME, question=QUESTION, **kwargs):
        return self.classify_many([paper], options, scheme, question, **kwargs)[0]

    def is_on_topic_many(self, papers, topic, definition=None):
        rows = [self.inputs.yesno(p, topic, definition) for p in papers]
        if not rows:
            return []
        return [r[0] for r in self._scores(rows, [[self.cfg["yes"], self.cfg["no"]]] * len(rows))]

    def is_on_topic(self, paper, topic, definition=None):
        return self.is_on_topic_many([paper], topic, definition)[0]


class PaperClassifier(ClassifierTasks):
    def __init__(self, model="maximerivest/paper-classifier-2B-v2", *, revision=None, device=None,
                 dtype=None, batch_size=4, paper_tokens=700):
        import torch
        from transformers import AutoModelForCausalLM, AutoTokenizer
        self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
        dtype = dtype or (torch.bfloat16 if self.device.startswith("cuda") else torch.float32)
        if batch_size < 1:
            raise ValueError("batch_size must be positive")
        self.batch_size = batch_size
        self.cfg = read_config(model, revision)
        self.tok = AutoTokenizer.from_pretrained(model, revision=revision)
        self.tok.padding_side = "left"
        self.inputs = PaperInputs(self.tok, self.cfg, paper_tokens=paper_tokens)
        self.model = AutoModelForCausalLM.from_pretrained(model, revision=revision, dtype=dtype,
                                                          attn_implementation="sdpa").to(self.device).eval()

    def _scores(self, input_ids, answer_ids, shared_prefix=False):
        import torch
        out = []
        with torch.inference_mode():
            for i in range(0, len(input_ids), self.batch_size):
                enc = self.tok.pad({"input_ids": input_ids[i:i+self.batch_size]}, return_tensors="pt").to(self.device)
                h = self.model.model(**enc, use_cache=False).last_hidden_state[:, -1, :].float()
                W = self.model.get_output_embeddings().weight
                for row, ids in zip(h, answer_ids[i:i+self.batch_size]):
                    out.append(torch.softmax(row @ W[ids].float().T, -1).cpu().tolist())
        return out


if __name__ == "__main__":
    import sys
    model = sys.argv[1] if len(sys.argv)>1 else "maximerivest/paper-classifier-2B-v2"
    clf = PaperClassifier(model)
    paper = paper_text("Habitat fragmentation and tropical forest birds",
                       "We surveyed bird communities across 40 forest fragments. Species richness declined with "
                       "fragment area and increased with connectivity.")
    print(clf.classify(paper, ["Ecology", "Ornithology", "Particle physics", "Economics"], scheme="Fields of science"))
    print("P(topic is central):", clf.is_on_topic(paper, "habitat fragmentation"))