File size: 14,091 Bytes
5952424
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""HF subword-encoder backbone (AutoModel/AutoTokenizer) plugged into the SAME recipe
CharBertWithHidden feeds (tagger/backbone.py): factored XPOS heads, edit-script lemmatizer,
UPOS aux head, ELMo-style scalar mix over all layers, and (via the joint model) the biaffine
dependency parser. This is the "swap the encoder, keep everything downstream identical" path
for a same-recipe cross-encoder ablation (XLM-R base/large, GreBERTa, AncientGreekBERT, Logion,
and -- as encoder-only extractions, see below -- the T5 encoder-decoder checkpoints GreTa/PhilTa).

Contract this file must match exactly (see CharBertWithHidden.forward in tagger/backbone.py):
  - encoder.cfg.d_model                      (TaggerModel/JointModel read this)
  - encoder.blocks                           (layerwise-LR-decay walk + mix_w sizing)
  - encoder.return_layers  (bool, settable)  (TaggerModel flips this on for scalar_mix)
  - encoder.forward(batch) -> dict(layers=[...], hidden=(B,T,D))
      len(layers) == len(encoder.blocks) whenever return_layers is True; ignored otherwise.

Design decisions that deviate from a raw char encoder (flagged explicitly, not silently):
  - `.blocks` is the HF model's real transformer-layer ModuleList (`model.encoder.layer` for
    every BERT/RoBERTa/XLM-R-style AutoModel checked here -- GreBerta, Ancient-Greek-BERT,
    Logion -- or `model.encoder.block` for the T5Stack used by T5EncoderModel -- GreTa, PhilTa).
    `_find_layer_list` tries a short list of known attribute paths and raises a clear error if
    none match, rather than guessing silently for an unseen architecture.
  - `output_hidden_states=True` returns L+1 hidden states: [embeddings, layer_1, ..., layer_L].
    CharBertWithHidden's contract needs `layers` to have exactly `len(blocks) == L` entries
    plus one more distinct `hidden`. There is no extra final-norm transform sitting on top of
    the last block in a stock encoder-only HF model (unlike CharBERT's separate `norm_out`), so
    the closest faithful analogue is: `layers = hidden_states[:-1]` (embeddings + the first L-1
    block outputs -- L entries) and `hidden = hidden_states[-1]` (the true final block output).
    Every distinct hidden state the model produces is therefore used exactly once as a mix
    component, and the non-scalar-mix default (`out["hidden"]`) is genuinely the last layer --
    nothing is duplicated or dropped. T5Stack (checked directly against its HF source) collects
    `all_hidden_states` with the exact same bookkeeping -- one entry appended before each block,
    plus one final entry that IS post-`final_layer_norm` -- so this contract holds verbatim for
    the T5-encoder-only path too; no special-casing needed in `forward()` itself.
  - GreTa/PhilTa (bowphs/GreTa, bowphs/PhilTa) are T5 encoder-decoder checkpoints. This ablation
    never uses T5's native generative/seq2seq decoding for anything (not lemmatization, not
    tagging) -- the decoder is never even instantiated. `load_hf_backbone` detects `model_type
    == "t5"` from the config and loads `T5EncoderModel.from_pretrained(...)` instead of
    `AutoModel`: this builds ONLY `shared` (the embedding matrix) + `encoder` (a T5Stack) +
    `encoder.final_layer_norm`, silently dropping the `decoder.*`/`lm_head.*` weights on load.
    From that point on GreTa/PhilTa are just another `HFBackboneWithHidden`-wrapped encoder --
    same forward(), same word/subword alignment, same scalar mix, same tagger/lemma/UPOS/biaffine
    heads downstream -- with two config-shape differences absorbed only at load time:
      * T5Config has no `hidden_size`/`num_hidden_layers`; it uses `d_model`/`num_layers`
        instead (`_resolve_dims` below reads the right pair off `model_type`).
      * T5Stack has no `.embeddings` submodule (no learned positional embeddings at all -- T5
        uses relative-attention-bias parameters that live inside each block, so they already
        ride the normal per-block LLRD schedule); its tied input embedding is the top-level
        `.shared` matrix. `_find_embeddings` (used by `param_groups_hf`) checks for `.shared`
        as a fallback path so that lone matrix still gets the same shallowest-LR treatment
        BERT-style embeddings get, instead of silently falling into the flat-LR catch-all.
  - Word/subword alignment does NOT reuse Stoicheia's char-level normalize_record. It tokenizes
    the whole sentence (every token, Greek and non-Greek) with `is_split_into_words=True` and
    reads back `BatchEncoding.word_ids()`, which fast (Rust-backed) HF tokenizers support
    directly. See tagger/dataset.py::encode_sentence_hf for the alignment/labels code -- this
    module only owns encoder + tokenizer loading. Confirmed empirically for both T5TokenizerFast
    checkpoints (GreTa, PhilTa): word_ids() round-trips correctly.
  - RoBERTa/GPT2-BPE-style fast tokenizers refuse `is_split_into_words=True` unless constructed
    with `add_prefix_space=True` (confirmed empirically for bowphs/GreBerta); BERT/WordPiece
    fast tokenizers accept and silently ignore that kwarg (confirmed for Ancient-Greek-BERT and
    the local Logion checkpoint); PhilTa's T5TokenizerFast raises TypeError on it (confirmed
    empirically) and falls back cleanly to the no-kwarg load already handled below. So
    `add_prefix_space=True` is passed unconditionally, with a fallback to omitting it only if a
    given tokenizer class rejects the kwarg outright.
"""
from __future__ import annotations

from dataclasses import dataclass

import torch.nn as nn

# Attribute paths (tuples walked via getattr) known to hold the transformer layer ModuleList
# for AutoModel-loaded encoder-only checkpoints. Extend this list before trusting a new
# architecture -- _find_layer_list raises loudly rather than guessing.
_LAYER_PATHS = [
    ("encoder", "layer"),     # BERT / RoBERTa / XLM-RoBERTa / ELECTRA / Longformer, ...
    ("transformer", "layer"), # DistilBERT
    ("encoder", "layers"),    # a few newer encoder implementations spell it plural
    ("encoder", "block"),     # T5Stack (T5EncoderModel.encoder.block) -- GreTa, PhilTa
]

# Same idea for the (single) embedding module/matrix an encoder holds, used only by
# param_groups_hf to give embeddings the shallowest LLRD rate. Returns None (not a raise) when
# nothing matches -- callers fall back to the flat-LR catch-all, same as before this existed.
_EMB_PATHS = [
    ("embeddings",),  # BERT / RoBERTa / XLM-RoBERTa / ELECTRA / Longformer, ...
    ("shared",),      # T5EncoderModel's tied input embedding matrix (no positional-embedding
                      # module to go with it -- T5 uses relative-attention-bias params living
                      # inside encoder.block instead, so those already get the per-block LLRD
                      # rate the normal blocks loop assigns)
]


def _find_layer_list(hf_model):
    for path in _LAYER_PATHS:
        obj = hf_model
        for attr in path:
            obj = getattr(obj, attr, None)
            if obj is None:
                break
        if obj is not None and len(obj) > 0:
            return obj
    raise RuntimeError(
        f"tagger.hf_backbone: could not find a transformer layer list on "
        f"{type(hf_model).__name__}; add its attribute path to _LAYER_PATHS.")


def _find_embeddings(hf_model):
    for path in _EMB_PATHS:
        obj = hf_model
        for attr in path:
            obj = getattr(obj, attr, None)
            if obj is None:
                break
        if obj is not None:
            return obj
    return None


# model_type (from AutoConfig) -> HF class name that extracts JUST the encoder stack from an
# encoder-decoder checkpoint, never instantiating (or loading weights into) the decoder. Add an
# entry here before pointing this ablation at another seq2seq architecture (e.g. "mt5" ->
# "MT5EncoderModel"); anything not in this dict falls through to plain AutoModel, unchanged.
_ENCODER_ONLY_CLASSES = {
    "t5": "T5EncoderModel",   # bowphs/GreTa, bowphs/PhilTa
}


def _resolve_dims(cfg):
    """(d_model, n_layers) off a HF config, robust to T5Config's different attribute names.

    T5Config has no `hidden_size`/`num_hidden_layers` (its encoder and decoder stacks can differ
    in depth, so there's no single "num_hidden_layers"); it spells these `d_model`/`num_layers`
    (the latter is specifically the ENCODER stack's depth -- decoder depth is the separate
    `num_decoder_layers`, never read here since the decoder is never built on this path).
    """
    if getattr(cfg, "model_type", None) == "t5":
        return cfg.d_model, cfg.num_layers
    return cfg.hidden_size, cfg.num_hidden_layers


@dataclass
class HFConfig:
    d_model: int
    n_layers: int
    name_or_path: str


class HFBackboneWithHidden(nn.Module):
    """Wraps a HF AutoModel so it slots in wherever CharBertWithHidden does. See module
    docstring for the exact contract and the layer/hidden-state bookkeeping decision."""

    def __init__(self, hf_model, cfg: HFConfig):
        super().__init__()
        self.model = hf_model
        self.cfg = cfg
        self.return_layers = False   # flipped on by TaggerModel.__init__ when scalar_mix=True

    @property
    def blocks(self):
        return _find_layer_list(self.model)

    def forward(self, batch):
        out = self.model(input_ids=batch["input_ids"], attention_mask=batch["attention_mask"],
                          output_hidden_states=True)
        hs = out.hidden_states                       # tuple, len == len(self.blocks) + 1
        hidden = hs[-1]
        layers = list(hs[:-1]) if self.return_layers else []
        return dict(layers=layers, hidden=hidden)


def load_hf_backbone(name_or_path, device, add_prefix_space=True):
    """Load a standard HF encoder (hub id or local directory) into a hidden-exposing wrapper.

    Mirrors tagger.backbone.load_backbone's return shape as closely as a different checkpoint
    format allows: (encoder, pretrain_cfg_dict, tokenizer). `tokenizer` is the extra piece (the
    CharBERT path has none -- normalize_record is a free function) that tagger/dataset.py needs
    to switch TaggerDataset onto the subword batching path.
    """
    import transformers
    from transformers import AutoConfig, AutoModel, AutoTokenizer

    try:
        tok = AutoTokenizer.from_pretrained(name_or_path, add_prefix_space=add_prefix_space)
    except TypeError:
        # a handful of slow/legacy tokenizer classes don't accept add_prefix_space at all
        tok = AutoTokenizer.from_pretrained(name_or_path)
    if not tok.is_fast:
        raise RuntimeError(
            f"tagger.hf_backbone: {name_or_path} loaded a slow tokenizer; the HF backbone path "
            "needs a fast (Rust-backed) tokenizer for BatchEncoding.word_ids() alignment.")

    model_type = AutoConfig.from_pretrained(name_or_path).model_type
    enc_only_cls_name = _ENCODER_ONLY_CLASSES.get(model_type)
    if enc_only_cls_name is not None:
        # Encoder-decoder checkpoint (T5 family): extract the encoder stack ONLY. This never
        # builds, loads weights into, or runs the decoder -- no seq2seq generation happens
        # anywhere in this ablation. From here down GreTa/PhilTa are indistinguishable from any
        # other AutoModel encoder (see module docstring).
        enc_only_cls = getattr(transformers, enc_only_cls_name)
        hf_model = enc_only_cls.from_pretrained(name_or_path)
    else:
        hf_model = AutoModel.from_pretrained(name_or_path)
    d_model, n_layers = _resolve_dims(hf_model.config)
    cfg = HFConfig(d_model=d_model, n_layers=n_layers, name_or_path=str(name_or_path))
    encoder = HFBackboneWithHidden(hf_model, cfg).to(device)
    pretrain_cfg = dict(kind="hf", name_or_path=str(name_or_path),
                        hidden_size=cfg.d_model, num_hidden_layers=cfg.n_layers)
    return encoder, pretrain_cfg, tok


def param_groups_hf(model, cfg):
    """Layerwise-LR-decay param groups for an HF backbone -- the same convention as
    tagger.train.param_groups (decay only matrices, embeddings at the shallowest LR, LLRD walk
    up through encoder.blocks, heads at flat lr_head) but without any of the CharBERT-specific
    e_char/e_bnd/e_dia/e_punct embeddings or head_char/head_bnd/... pretraining-head freezing,
    which simply don't exist on a HF backbone."""
    llrd = cfg.get("llrd", 0.95)
    lr_enc, lr_head, wd = cfg["lr_enc"], cfg["lr_head"], cfg.get("wd", 0.01)
    enc = model.encoder
    blocks = enc.blocks
    depth = len(blocks)
    groups = {}

    def add(p, lr, is_enc=True):
        key = (lr, 0.0 if p.ndim < 2 else wd, is_enc)
        groups.setdefault(key, []).append(p)

    block_params = set()
    for blk in blocks:
        for p in blk.parameters():
            block_params.add(id(p))

    emb_lr = lr_enc * llrd ** depth
    emb_mod = _find_embeddings(enc.model)
    emb_params = set()
    if emb_mod is not None:
        for p in emb_mod.parameters():
            emb_params.add(id(p))
            add(p, emb_lr)

    for i, blk in enumerate(blocks):
        for p in blk.parameters():
            add(p, lr_enc * llrd ** (depth - 1 - i))

    # anything else under the HF backbone not covered above (e.g. a pooler head nobody uses
    # downstream) rides along at the plain encoder LR, same "harmless" convention as the
    # CharBERT path's pretraining-head groups
    for p in enc.model.parameters():
        if id(p) not in block_params and id(p) not in emb_params:
            add(p, lr_enc)

    heads = [model.xpos_heads, model.head_script, model.head_upos]
    if model.head_flat is not None:
        heads.append(model.head_flat)
    for m in heads:
        for p in m.parameters():
            add(p, lr_head, is_enc=False)
    if hasattr(model, "mix_w"):
        add(model.mix_w, lr_head, is_enc=False)
    cap_emb = getattr(enc, "cap_emb", None)
    if cap_emb is not None:
        for p in cap_emb.parameters():
            add(p, lr_head)

    return [dict(params=ps, lr=lr, weight_decay=w, base_lr=lr, is_enc=e)
            for (lr, w, e), ps in groups.items()]