File size: 19,058 Bytes
b158f2b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75a96eb
 
b158f2b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aa79f0e
 
 
 
 
 
 
 
75a96eb
aa79f0e
 
 
 
 
 
 
 
 
 
 
 
 
 
b158f2b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
"""
echo_dsrn/modeling_generative_clf.py
────────────────────────────────────────────────────────────────────────────
EchoForGenerativeClassification
────────────────────────────────────────────────────────────────────────────
A sequence classifier built on top of EchoForCausalLM using *constrained
scoring* instead of a linear head.

Why a new class instead of EchoForSequenceClassification
─────────────────────────────────────────────────────────
EchoForSequenceClassification uses a single nn.Linear layer seeded from the
lm_head rows. This works perfectly for adapters with single-token labels (e.g.
NSFW: "0" / "1"). For multi-token labels like "weather_query" or
"iot_hue_lightchange" the mean-pooling approximation loses too much signal.

EchoForGenerativeClassification instead computes, for each candidate label L:

    score(L | x) = Ξ£_t  log P(token_t | x, token_1..t-1)

i.e. the sum of log-probabilities of each token in L conditioned on the input
and the previously generated label tokens. These 60 scores form the logits
returned by forward(), making the model a drop-in AutoModelForSequenceClassification.

HuggingFace API compatibility
──────────────────────────────
β€’ Registered with AutoModelForSequenceClassification via __init__.py
β€’ forward() returns SequenceClassifierOutputWithPast (logits, loss, hidden_states)
β€’ classify(text, tokenizer) convenience method mirrors EchoForSequenceClassification
β€’ from_causal_lm() factory for converting a merged CausalLM checkpoint
β€’ config.auto_map updated by the factory to point here
β€’ No new weights are added β€” the model is the base lm with the adapter merged

Scoring algorithm
─────────────────
For each label string L_i (from config.id2label):
  1. Tokenise L_i (no BOS/EOS, no special tokens)
  2. Concatenate [input_ids, label_tokens] along seq_len
  3. Run a single forward pass on the combined sequence
  4. Sum log-softmax values at the positions where label tokens are predicted
     (i.e. positions [n_input, n_input+1, ..., n_input+len(label)-1])

This is O(n_labels) forward passes β€” for 60 MASSIVE intents on short utterances
(avg 7 tokens) it runs in ~60ms on GPU, which is acceptable for inference.
Training is NOT required β€” the adapter's generative knowledge is used directly.
"""

from __future__ import annotations

import typing
from typing import List, Optional, Tuple, Union

if typing.TYPE_CHECKING:
    # Force HF trust_remote_code to bundle nested dependencies
    from .triton_scan import triton_dsrn_parallel_scan
    from .utils import rms_norm_fn

import torch
import torch.nn.functional as F
from transformers.modeling_outputs import SequenceClassifierOutputWithPast

from .configuration_echo import EchoConfig
from .modeling_echo import EchoForCausalLM


class EchoForGenerativeClassification(EchoForCausalLM):
    """
    Intent / multi-label classifier using constrained generative scoring.

    The model is identical to EchoForCausalLM plus:
    β€’ A cache of tokenised label sequences built from config.id2label
    β€’ A forward() that scores all labels and returns (B, num_labels) logits
    β€’ No new trainable parameters

    Usage
    ─────
        from echo_dsrn import EchoForGenerativeClassification
        from transformers import AutoTokenizer

        model = EchoForGenerativeClassification.from_pretrained(
            "ethicalabs/Echo-SmolTools-114M-Intent-CLF-Gen",
            trust_remote_code=True,
        )
        tok = AutoTokenizer.from_pretrained(
            "ethicalabs/Echo-SmolTools-114M-Intent-CLF-Gen",
            trust_remote_code=True,
        )
        label, probs = model.classify("What time is it in Tokyo?", tok)
        # β†’ ("datetime_query", tensor([...]))
    """

    # Let HF serialisation know this is a classification model
    _no_split_modules = []

    def __init__(self, config: EchoConfig):
        super().__init__(config)
        # Label token cache β€” populated lazily on first call that needs it
        # (requires a tokenizer, which we don't have at __init__ time)
        self._label_token_ids: Optional[List[List[int]]] = None

    # ------------------------------------------------------------------
    # Label token cache
    # ------------------------------------------------------------------

    def _build_label_cache(self, tokenizer) -> List[List[int]]:
        """
        Tokenise every label string in config.id2label and cache the result.
        Called lazily on the first forward/classify call.
        """
        cache: List[List[int]] = []
        n = self.config.num_labels
        for idx in range(n):
            label_str = self.config.id2label[idx]
            # Encode without special tokens β€” the label is a continuation,
            # not a standalone sentence.
            tids = tokenizer.encode(label_str, add_special_tokens=False)
            if not tids:
                raise ValueError(
                    f"Label '{label_str}' (id={idx}) tokenises to an empty sequence. "
                    "Please check your tokenizer and label strings."
                )
            cache.append(tids)
        self._label_token_ids = cache
        return cache

    # ------------------------------------------------------------------
    # Scoring helpers
    # ------------------------------------------------------------------

    @torch.inference_mode()
    def _score_labels(
        self,
        input_ids: torch.Tensor,  # (B, S)
        attention_mask: torch.Tensor,  # (B, S)
        label_cache: List[List[int]],
    ) -> torch.Tensor:
        """
        Return a (B, num_labels) tensor of log-probability scores.

        For each sample b and label L_i:
            score[b, i] = Ξ£_t log P(L_i[t] | input_ids[b], L_i[:t])
        """
        device = input_ids.device
        B, S = input_ids.shape
        num_labels = len(label_cache)
        scores = torch.full((B, num_labels), float("-inf"), device=device)

        # 1. Process the prompt once and cache the state
        base_out = super().forward(
            input_ids=input_ids,
            attention_mask=attention_mask,
            use_cache=True,
        )
        prompt_pkv = base_out.past_key_values
        # Logits predicting the first label token (last token of the prompt)
        prompt_last_logits = base_out.logits[:, -1:, :]  # (B, 1, V)

        # Helper to clone the custom EchoCache safely
        def clone_pkv(pkv):
            if pkv is None:
                return None
            if hasattr(pkv, "states"):
                from .modeling_echo import EchoCache

                new_states = [tuple(t.clone() for t in state_tuple) for state_tuple in pkv.states]
                return EchoCache(new_states)
            elif isinstance(pkv, (list, tuple)):
                return [tuple(t.clone() for t in state_tuple) for state_tuple in pkv]
            return pkv

        for i, label_tids in enumerate(label_cache):
            L = len(label_tids)
            label_t = (
                torch.tensor(label_tids, dtype=torch.long, device=device).unsqueeze(0).expand(B, -1)
            )  # (B, L)

            # 2. Forward pass ONLY the label tokens using the cloned cache
            label_out = super().forward(
                input_ids=label_t,
                attention_mask=None,  # EchoModel handles this causally for recurrent steps
                past_key_values=clone_pkv(prompt_pkv),
                use_cache=False,
            )

            # 3. Splice the logits together.
            # The first label token is predicted by prompt_last_logits.
            # The remaining label tokens are predicted by label_out.logits[:, :-1, :]
            if L == 1:
                label_logits = prompt_last_logits
            else:
                label_logits = torch.cat(
                    [prompt_last_logits, label_out.logits[:, :-1, :]], dim=1
                )  # (B, L, V)

            log_probs = F.log_softmax(label_logits, dim=-1)  # (B, L, V)

            # Gather log-prob of the correct next token at each label position
            label_t_expanded = label_t.unsqueeze(-1)  # (B, L, 1)
            token_log_probs = log_probs.gather(dim=-1, index=label_t_expanded).squeeze(-1)  # (B, L)

            # Sum log-probs across label tokens β†’ scalar score per sample
            scores[:, i] = token_log_probs.sum(dim=-1)  # (B,)

        return scores  # (B, num_labels) β€” these are log-prob sums (higher = better)

    # ------------------------------------------------------------------
    # forward()
    # ------------------------------------------------------------------

    def forward(
        self,
        input_ids: Optional[torch.LongTensor] = None,
        attention_mask: Optional[torch.Tensor] = None,
        labels: Optional[torch.LongTensor] = None,
        # Tokenizer is needed for label scoring; injected via classify() or
        # set once via set_tokenizer().
        _tokenizer=None,
        **kwargs,
    ) -> SequenceClassifierOutputWithPast:
        """
        Returns SequenceClassifierOutputWithPast with:
          β€’ logits  : (B, num_labels) log-prob sums β€” higher = more likely intent
          β€’ loss    : cross-entropy loss if `labels` is provided, else None

        Note: kwargs are accepted but ignored (past_key_values, use_cache, etc.)
        to maintain drop-in compatibility with the HF pipeline.
        """
        tokenizer = _tokenizer or self._tokenizer_ref
        if tokenizer is None:
            raise RuntimeError(
                "EchoForGenerativeClassification.forward() requires a tokenizer for "
                "label scoring. Either call model.set_tokenizer(tok) once after loading, "
                "or use the classify() convenience method."
            )

        if self._label_token_ids is None:
            self._build_label_cache(tokenizer)

        logits = self._score_labels(input_ids, attention_mask, self._label_token_ids)

        loss = None
        if labels is not None:
            loss = F.cross_entropy(logits, labels)

        return SequenceClassifierOutputWithPast(
            loss=loss,
            logits=logits,
        )

    # ------------------------------------------------------------------
    # Tokenizer binding
    # ------------------------------------------------------------------

    _tokenizer_ref = None  # class-level default; overridden per-instance

    def set_tokenizer(self, tokenizer) -> "EchoForGenerativeClassification":
        """
        Bind a tokenizer so forward() can score label strings.
        Call this once after loading the model:

            model = EchoForGenerativeClassification.from_pretrained(...)
            model.set_tokenizer(tokenizer)
        """
        self._tokenizer_ref = tokenizer
        if self._label_token_ids is None:
            self._build_label_cache(tokenizer)
        return self

    @classmethod
    def from_pretrained(cls, pretrained_model_name_or_path, *model_args, **kwargs):
        """
        Override from_pretrained to automatically load and bind the tokenizer
        from the same checkpoint, so that forward() works out of the box when
        loaded via pipeline() or AutoModelForSequenceClassification without a
        manual set_tokenizer() call.
        """
        model = super().from_pretrained(pretrained_model_name_or_path, *model_args, **kwargs)
        try:
            from transformers import AutoTokenizer

            tok = AutoTokenizer.from_pretrained(
                pretrained_model_name_or_path,
                trust_remote_code=kwargs.get("trust_remote_code", False),
            )
            model.set_tokenizer(tok)
        except Exception:
            # Best-effort: if tokenizer loading fails, the user can still call
            # set_tokenizer() manually before running forward().
            pass
        return model

    # ------------------------------------------------------------------
    # High-level inference API
    # ------------------------------------------------------------------

    # Default user-message template; matches what the Intent PEFT was trained on.
    _DEFAULT_USER_TEMPLATE = "Classify the intent of the following request: {utt}"

    def _format_prompts(
        self, texts: List[str], tokenizer, system_prompt: Optional[str], user_template: str
    ) -> List[str]:
        """
        Apply the chat template to a list of raw utterances.
        Falls back to raw text if the tokenizer has no chat template.
        """
        if not hasattr(tokenizer, "apply_chat_template") or tokenizer.chat_template is None:
            return texts  # bare tokenizer β€” score raw text directly

        formatted = []
        for utt in texts:
            messages = []
            if system_prompt:
                messages.append({"role": "system", "content": system_prompt})
            messages.append({"role": "user", "content": user_template.format(utt=utt)})
            prompt = tokenizer.apply_chat_template(
                messages, tokenize=False, add_generation_prompt=True
            )
            formatted.append(prompt)
        return formatted

    @torch.inference_mode()
    def classify(
        self,
        text: Union[str, List[str]],
        tokenizer,
        system_prompt: Optional[str] = None,
        user_template: Optional[str] = None,
        max_length: int = 256,
    ) -> Union[
        Tuple[str, torch.Tensor],
        Tuple[List[str], torch.Tensor],
    ]:
        """
        Classify text (or a list of texts) into one of the intent classes.

        The input is formatted with the same chat template used during adapter
        training. The system_prompt and user_template default to the values
        baked into config at merge time.

        Args:
            text          : Raw utterance(s) β€” no prompt wrapping needed.
            tokenizer     : HF tokenizer for the model.
            system_prompt : Override the system message (optional).
            user_template : Override the user message template (optional).
                            Must contain ``{utt}`` as the utterance placeholder.
            max_length    : Max tokenised length (default 256 to fit chat template).

        Returns (single input):
            (label_str, probs_tensor)   β€” probs shape (num_labels,)

        Returns (batch input):
            (label_list, probs_tensor)  β€” probs shape (B, num_labels)
        """
        self.set_tokenizer(tokenizer)
        single = isinstance(text, str)
        texts = [text] if single else text

        # Resolve prompt components: args > config > defaults
        sys_prompt = system_prompt or getattr(self.config, "classification_system_prompt", None)
        usr_template = user_template or getattr(
            self.config, "classification_user_template", self._DEFAULT_USER_TEMPLATE
        )

        # Apply the chat template the adapter was trained with
        formatted = self._format_prompts(texts, tokenizer, sys_prompt, usr_template)

        enc = tokenizer(
            formatted,
            return_tensors="pt",
            padding=True,
            truncation=True,
            max_length=max_length,
        )
        device = next(self.parameters()).device
        input_ids = enc["input_ids"].to(device)
        attention_mask = enc["attention_mask"].to(device)

        out = self.forward(
            input_ids=input_ids,
            attention_mask=attention_mask,
            _tokenizer=tokenizer,
        )
        probs = torch.softmax(out.logits, dim=-1)  # (B, num_labels)

        if single:
            pred_idx = probs[0].argmax().item()
            label_str = self.config.id2label[pred_idx]
            return label_str, probs[0]
        else:
            pred_idxs = probs.argmax(dim=-1).tolist()
            label_strs = [self.config.id2label[i] for i in pred_idxs]
            return label_strs, probs

    # ------------------------------------------------------------------
    # Factory: from_causal_lm
    # ------------------------------------------------------------------

    @classmethod
    def from_causal_lm(
        cls,
        causal_lm_model: EchoForCausalLM,
        num_labels: int,
        id2label: dict,
        label2id: Optional[dict] = None,
        system_prompt: Optional[str] = None,
        user_template: Optional[str] = None,
    ) -> "EchoForGenerativeClassification":
        """
        Construct an EchoForGenerativeClassification from a (possibly
        adapter-merged) EchoForCausalLM instance.

        No new weights are added β€” all parameters come from causal_lm_model.
        The config is updated in-place to record classification metadata.

        Args:
            causal_lm_model : A loaded (and optionally merged) EchoForCausalLM.
            num_labels       : Number of intent classes (e.g. 60 for MASSIVE).
            id2label         : Mapping {int_idx: label_str}.
            label2id         : Optional reverse mapping; auto-derived if None.
            system_prompt    : System message to bake into config (used by classify()).
            user_template    : User message template to bake into config.
                               Must contain ``{utt}`` placeholder.

        Returns:
            EchoForGenerativeClassification ready for inference.
        """
        if label2id is None:
            label2id = {v: int(k) for k, v in id2label.items()}

        config = causal_lm_model.config
        config.num_labels = num_labels
        config.id2label = {int(k): v for k, v in id2label.items()}
        config.label2id = {v: int(k) for k, v in id2label.items()}

        # Bake prompt components into config so the model is self-contained
        if system_prompt is not None:
            config.classification_system_prompt = system_prompt
        if user_template is not None:
            config.classification_user_template = user_template

        # Point auto_map to this module so HF can find it with trust_remote_code
        config.auto_map = {
            **getattr(config, "auto_map", {}),
            "AutoModelForSequenceClassification": (
                "modeling_generative_clf.EchoForGenerativeClassification"
            ),
        }

        # Reuse all weights β€” no state_dict copy needed, just change the class
        gen_clf = cls(config)
        gen_clf.load_state_dict(causal_lm_model.state_dict())

        # Cast to same dtype as source
        src_dtype = next(causal_lm_model.parameters()).dtype
        gen_clf.to(src_dtype)

        return gen_clf