File size: 5,476 Bytes
4284421
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""HS6 product classifier: XLM-RoBERTa encoder + one flat linear head.

HS4 and HS2 are not separate heads. They are marginals of the same HS6
distribution (logsumexp over the children of each parent), so the levels are
consistent by construction: the model cannot name one heading at 4 digits and a
code from a different heading at 6 digits.

The forward pass below must stay identical to the one used in training,
otherwise the released weights do not mean what the metrics say they mean.
"""

from dataclasses import dataclass
from typing import List, Optional

import torch
import torch.nn as nn
from transformers.modeling_outputs import ModelOutput
from transformers.modeling_utils import PreTrainedModel
from transformers.models.xlm_roberta.configuration_xlm_roberta import XLMRobertaConfig
from transformers.models.xlm_roberta.modeling_xlm_roberta import XLMRobertaModel

from .configuration_hs6 import HS6ClassifierConfig

# fields that belong to the classifier, not to the encoder
_HEAD_ONLY = ("n2", "n4", "n6", "head_dropout", "pooling", "recommended_max_length",
              "id2hs4", "id2hs2", "id2label", "label2id", "auto_map", "architectures",
              "model_type")


def _encoder_config(config: HS6ClassifierConfig) -> XLMRobertaConfig:
    """A plain XLM-R config, so the encoder does not warn about the wrapper type."""
    raw = {k: v for k, v in config.to_dict().items() if k not in _HEAD_ONLY}
    return XLMRobertaConfig(**raw)


@dataclass
class HS6ClassifierOutput(ModelOutput):
    """`logits` is the HS6 level, so the standard text-classification tooling works.

    `logits_hs4` / `logits_hs2` are the marginals over the same distribution.
    """

    loss: Optional[torch.FloatTensor] = None
    logits: Optional[torch.FloatTensor] = None
    logits_hs4: Optional[torch.FloatTensor] = None
    logits_hs2: Optional[torch.FloatTensor] = None


class HS6ClassifierModel(PreTrainedModel):
    config_class = HS6ClassifierConfig
    base_model_prefix = "encoder"
    supports_gradient_checkpointing = True

    def __init__(self, config: HS6ClassifierConfig):
        super().__init__(config)
        # no pooling layer: the head reads the CLS token of the last hidden state
        self.encoder = XLMRobertaModel(_encoder_config(config), add_pooling_layer=False)
        self.pooling = config.pooling
        self.drop = nn.Dropout(config.head_dropout)
        self.head_6 = nn.Linear(config.hidden_size, config.n6)
        self.n4, self.n2 = config.n4, config.n2
        # parent of every HS6 class: its first 4 and first 2 digits. Stored in the
        # checkpoint so the mapping cannot drift away from the trained weights.
        self.register_buffer("parent4", torch.zeros(config.n6, dtype=torch.long))
        self.register_buffer("parent2", torch.zeros(config.n6, dtype=torch.long))
        self.post_init()

    def _marginal(self, l6, parent, n_parent):
        mx = l6.max(1, keepdim=True).values
        e = (l6 - mx).exp()
        s = torch.zeros(l6.size(0), n_parent, device=l6.device, dtype=e.dtype)
        s.index_add_(1, parent, e)
        return s.clamp_min(1e-20).log() + mx

    def forward(
        self,
        input_ids: Optional[torch.LongTensor] = None,
        attention_mask: Optional[torch.Tensor] = None,
        labels: Optional[torch.LongTensor] = None,
        return_dict: Optional[bool] = None,
        **kwargs,
    ):
        return_dict = True if return_dict is None else return_dict
        out = self.encoder(input_ids=input_ids, attention_mask=attention_mask)

        if self.pooling == "mean":
            h = out.last_hidden_state
            m = attention_mask.unsqueeze(-1).to(h.dtype)
            pooled = (h * m).sum(1) / m.sum(1).clamp(min=1)
        else:
            pooled = out.last_hidden_state[:, 0, :]

        l6 = self.head_6(self.drop(pooled))
        l4 = self._marginal(l6, self.parent4, self.n4)
        l2 = self._marginal(l6, self.parent2, self.n2)

        loss = None
        if labels is not None:
            loss = nn.functional.cross_entropy(l6, labels)

        if not return_dict:
            return (loss, l6, l4, l2) if loss is not None else (l6, l4, l2)
        return HS6ClassifierOutput(loss=loss, logits=l6, logits_hs4=l4, logits_hs2=l2)

    @torch.no_grad()
    def classify(self, texts, tokenizer, top_k: int = 5, batch_size: int = 16,
                 max_length: Optional[int] = None) -> List[List[dict]]:
        """Convenience wrapper: texts in, ranked HS6 codes with probabilities out."""
        if isinstance(texts, str):
            texts = [texts]
        max_length = max_length or self.config.recommended_max_length
        device = next(self.parameters()).device
        results = []
        for start in range(0, len(texts), batch_size):
            chunk = [t if isinstance(t, str) and t.strip() else " "
                     for t in texts[start:start + batch_size]]
            enc = tokenizer(chunk, truncation=True, max_length=max_length,
                            padding=True, return_tensors="pt").to(device)
            logits = self(**enc).logits
            probs = torch.softmax(logits.float(), dim=1)
            conf, idx = probs.topk(min(top_k, probs.size(1)), dim=1)
            for c, i in zip(conf.cpu().tolist(), idx.cpu().tolist()):
                results.append([
                    {"hs6": self.config.id2label[j], "score": round(v, 6)}
                    for j, v in zip(i, c)
                ])
        return results