| """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 |
|
|
| |
| |
| |
| _LAYER_PATHS = [ |
| ("encoder", "layer"), |
| ("transformer", "layer"), |
| ("encoder", "layers"), |
| ("encoder", "block"), |
| ] |
|
|
| |
| |
| |
| _EMB_PATHS = [ |
| ("embeddings",), |
| ("shared",), |
| |
| |
| |
| ] |
|
|
|
|
| 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 |
|
|
|
|
| |
| |
| |
| |
| _ENCODER_ONLY_CLASSES = { |
| "t5": "T5EncoderModel", |
| } |
|
|
|
|
| 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 |
|
|
| @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 |
| 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: |
| |
| 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: |
| |
| |
| |
| |
| 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)) |
|
|
| |
| |
| |
| 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()] |
|
|