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()]
|