AbstractPhil commited on
Commit
dbc600c
·
verified ·
1 Parent(s): 6b44062

modeling: AMOE_REPO resolves from config._name_or_path, not a hardcoded v2 - anchors are TRUNK-BOUND (v2 arms lose 31%% of their gain on the -B trunk, .7287 -> .6863; key realignment recovers 29%%). Dispatch is now the first candidate PRESENT in the loaded repo, with a local-dir hint on miss.

Browse files
Files changed (1) hide show
  1. modeling_captionbert.py +495 -456
modeling_captionbert.py CHANGED
@@ -1,456 +1,495 @@
1
- # ============================================================================
2
- # modeling_captionbert.py -- AbstractPhil/captionbert-8192-v2
3
- #
4
- # from transformers import AutoModel, AutoTokenizer
5
- # model = AutoModel.from_pretrained("AbstractPhil/captionbert-8192-v2",
6
- # trust_remote_code=True)
7
- # tok = AutoTokenizer.from_pretrained("google-bert/bert-base-uncased")
8
- # out = model(**tok(["a cat on a windowsill"], return_tensors="pt"))
9
- # emb = out.pooler_output # (B, 768) L2-normalized
10
- #
11
- # OR just: emb = model.encode(["a cat on a windowsill"])
12
- #
13
- # WITH AMOE ARMS (needs amoe-lora; imported lazily so the base model loads
14
- # without it):
15
- # model.attach_amoe() # shipped 3-arm collective
16
- # emb = model.encode([...]) # adapted
17
- # with model.amoe_off(): # the unsupervised baseline
18
- # base = model.encode([...])
19
- # model.set_amoe(["equiv"]) # one arm, DAMPED inside the dispatch
20
- # model.detach_amoe() # bit-exact restore, asserted
21
- #
22
- # ---------------------------------------------------------------------------
23
- # BREAKING CHANGE FROM v1 -- READ THIS IF YOU USED geolip-captionbert-8192
24
- # v1 returned the POOLED 768-d embedding as `last_hidden_state`. That is not
25
- # the transformers convention and it silently breaks anything expecting token
26
- # states. v2 follows the convention:
27
- # last_hidden_state : (B, L, 512) token states
28
- # pooler_output : (B, 768) L2-normalized embedding <-- the product
29
- # embedding : (B, 768) alias for pooler_output
30
- # If you are porting v1 code, `last_hidden_state` -> `pooler_output`.
31
- #
32
- # v1 also shipped an AlignmentBank. v2 does NOT. Measured on v1: the bank's
33
- # expert-consistency block varied 0.2% across samples and took 0.23% of its
34
- # projection energy while anchor distances took 98.70% -- because
35
- # `back = x @ R.T @ R` is a rotation round-trip and carries no data. Content
36
- # extensions belong in an AMOE anchor, which this repo ships separately.
37
- # ============================================================================
38
-
39
- import os
40
- from dataclasses import dataclass
41
- from types import SimpleNamespace
42
- from typing import List, Optional, Tuple, Union
43
-
44
- import torch
45
- import torch.nn as nn
46
- import torch.nn.functional as F
47
- from transformers import PretrainedConfig, PreTrainedModel
48
- from transformers.modeling_outputs import BaseModelOutputWithPooling
49
-
50
-
51
- class CaptionBertV2Config(PretrainedConfig):
52
- model_type = "captionbert_v2"
53
-
54
- def __init__(
55
- self,
56
- vocab_size: int = 30522,
57
- hidden_size: int = 512, # d_model
58
- num_hidden_layers: int = 12,
59
- num_attention_heads: int = 8,
60
- intermediate_size: int = 2048,
61
- output_dim: int = 768, # consensus space
62
- max_position_embeddings: int = 8192,
63
- hidden_dropout_prob: float = 0.1,
64
- pad_token_id: int = 0,
65
- pooling: str = "mean", # "mean" | "cls"
66
- **kwargs,
67
- ):
68
- super().__init__(pad_token_id=pad_token_id, **kwargs)
69
- self.vocab_size = vocab_size
70
- self.hidden_size = hidden_size
71
- self.num_hidden_layers = num_hidden_layers
72
- self.num_attention_heads = num_attention_heads
73
- self.intermediate_size = intermediate_size
74
- self.output_dim = output_dim
75
- self.max_position_embeddings = max_position_embeddings
76
- self.hidden_dropout_prob = hidden_dropout_prob
77
- self.pooling = pooling
78
-
79
-
80
- class CaptionBertV2Model(PreTrainedModel):
81
- """
82
- Standalone caption/sentence encoder distilled from the geometric consensus
83
- of five BERT-family teachers. No expert models at inference.
84
-
85
- Parameter names are deliberately NOT namespaced under a submodule so that
86
- the training checkpoint loads unchanged: token_emb, pos_emb, emb_norm,
87
- encoder.layers.*, output_proj.*.
88
- """
89
-
90
- config_class = CaptionBertV2Config
91
- base_model_prefix = "captionbert_v2"
92
- supports_gradient_checkpointing = True
93
-
94
- def __init__(self, config: CaptionBertV2Config):
95
- super().__init__(config)
96
- d = config.hidden_size
97
- self.token_emb = nn.Embedding(config.vocab_size, d,
98
- padding_idx=config.pad_token_id)
99
- self.pos_emb = nn.Embedding(config.max_position_embeddings, d)
100
- self.emb_norm = nn.LayerNorm(d)
101
- self.emb_drop = nn.Dropout(config.hidden_dropout_prob)
102
- layer = nn.TransformerEncoderLayer(
103
- d_model=d,
104
- nhead=config.num_attention_heads,
105
- dim_feedforward=config.intermediate_size,
106
- dropout=config.hidden_dropout_prob,
107
- activation="gelu",
108
- batch_first=True,
109
- norm_first=True,
110
- )
111
- self.encoder = nn.TransformerEncoder(
112
- layer, num_layers=config.num_hidden_layers, enable_nested_tensor=False)
113
- self.output_proj = nn.Sequential(
114
- nn.Linear(d, d), nn.GELU(), nn.LayerNorm(d), nn.Linear(d, config.output_dim))
115
- self.post_init()
116
-
117
- # -- HF plumbing --
118
- def get_input_embeddings(self):
119
- return self.token_emb
120
-
121
- def set_input_embeddings(self, value):
122
- self.token_emb = value
123
-
124
- def forward(
125
- self,
126
- input_ids: torch.LongTensor = None,
127
- attention_mask: Optional[torch.Tensor] = None,
128
- output_hidden_states: Optional[bool] = None,
129
- return_dict: Optional[bool] = None,
130
- **kwargs,
131
- ) -> Union[Tuple, BaseModelOutputWithPooling]:
132
- return_dict = return_dict if return_dict is not None else True
133
- L = input_ids.shape[1]
134
- pos = torch.arange(L, device=input_ids.device).unsqueeze(0)
135
- x = self.emb_drop(self.emb_norm(self.token_emb(input_ids) + self.pos_emb(pos)))
136
-
137
- kpm = (~attention_mask.bool()) if attention_mask is not None \
138
- else (input_ids == self.config.pad_token_id)
139
-
140
- hidden = [x] if output_hidden_states else None
141
- # Iterate the layers directly rather than calling self.encoder(...):
142
- # nn.TransformerEncoder's fast path inspects layer types, and an AMOE
143
- # anchor wraps each layer in a BlockWithAdapter that is not a
144
- # TransformerEncoderLayer. This keeps attach() a drop-in.
145
- for mod in self.encoder.layers:
146
- x = mod(x, src_key_padding_mask=kpm)
147
- if output_hidden_states:
148
- hidden.append(x)
149
- if self.encoder.norm is not None:
150
- x = self.encoder.norm(x)
151
-
152
- if self.config.pooling == "cls":
153
- pooled = x[:, 0]
154
- else:
155
- m = (attention_mask.unsqueeze(-1).to(x.dtype) if attention_mask is not None
156
- else (~kpm).unsqueeze(-1).to(x.dtype))
157
- pooled = (x * m).sum(1) / m.sum(1).clamp(min=1)
158
- embedding = F.normalize(self.output_proj(pooled), dim=-1)
159
-
160
- if not return_dict:
161
- return (x, embedding) + ((tuple(hidden),) if output_hidden_states else ())
162
- out = BaseModelOutputWithPooling(
163
- last_hidden_state=x, # (B, L, 512) token states
164
- pooler_output=embedding, # (B, 768) THE PRODUCT
165
- hidden_states=tuple(hidden) if output_hidden_states else None,
166
- )
167
- out.embedding = embedding # explicit alias
168
- return out
169
-
170
- @torch.no_grad()
171
- def encode(self, texts, tokenizer=None, batch_size: int = 128,
172
- max_length: int = 256, device=None) -> torch.Tensor:
173
- """Raw text -> (N, 768) L2-normalized embeddings."""
174
- if isinstance(texts, str):
175
- texts = [texts]
176
- if tokenizer is None:
177
- from transformers import AutoTokenizer
178
- tokenizer = AutoTokenizer.from_pretrained("google-bert/bert-base-uncased")
179
- device = device or next(self.parameters()).device
180
- was_training = self.training
181
- self.eval()
182
- out = []
183
- for i in range(0, len(texts), batch_size):
184
- t = tokenizer(list(texts[i:i + batch_size]), max_length=max_length,
185
- padding=True, truncation=True, return_tensors="pt").to(device)
186
- out.append(self(**t).pooler_output.float().cpu())
187
- if was_training:
188
- self.train()
189
- return torch.cat(out)
190
-
191
-
192
- # ---------------------------------------------------------------------------
193
- # AMOE binding -- lets amoe-lora attach anchors to this trunk unmodified.
194
- #
195
- # import amoe
196
- # from modeling_captionbert import CaptionBertV2Binding
197
- # h = amoe.attach(model, "amoe/moe/equiv.anchor.pt",
198
- # binding=CaptionBertV2Binding(d=model.config.hidden_size))
199
- #
200
- # amoe's PathBinding would find encoder.layers by dotted path but then read
201
- # model.config.hidden_size -- which works here because this IS a
202
- # PretrainedConfig. The explicit binding is kept for plain-nn.Module use.
203
- # ---------------------------------------------------------------------------
204
-
205
- @dataclass
206
- class CaptionBertV2Binding:
207
- d: int = 512
208
- name: str = "captionbert_v2"
209
-
210
- def layers(self, model):
211
- return model.encoder.layers
212
-
213
- def set_layers(self, model, new):
214
- model.encoder.layers = nn.ModuleList(new)
215
-
216
- def hidden_size(self, model) -> int:
217
- return int(self.d)
218
-
219
-
220
- # ---------------------------------------------------------------------------
221
- # AMOE ARMS -- attach / toggle / detach, from the same AutoModel object.
222
- #
223
- # model = AutoModel.from_pretrained(REPO, trust_remote_code=True)
224
- # model.attach_amoe() # shipped 3-arm collective
225
- # emb = model.encode(["a cat on a windowsill"])
226
- # with model.amoe_off(): # the unsupervised baseline
227
- # base = model.encode(["a cat on a windowsill"])
228
- # model.set_amoe(["equiv"]) # one arm inside the dispatch
229
- # model.detach_amoe() # bit-exact restore, asserted
230
- #
231
- # `amoe` is imported LAZILY: this file is executed by every
232
- # AutoModel.from_pretrained(trust_remote_code=True), and the base model must
233
- # load on a machine that has never heard of amoe-lora.
234
- #
235
- # Masking never renormalizes (the damping law), so a single arm inside the
236
- # dispatch is DAMPED and reads lower than that arm trained alone. That is the
237
- # mechanism, not a bug -- see the model card.
238
- # ---------------------------------------------------------------------------
239
-
240
- AMOE_REPO = "AbstractPhil/captionbert-8192-v2"
241
- AMOE_LIB = "amoe/arms"
242
- AMOE_DEFAULT_DISPATCH = "amoe/collective/captionbert-v2-collective.dispatch.pt"
243
- # campaign folders to search when the canonical library has not been published
244
- AMOE_FALLBACKS = lambda n: [f"amoe/collective/{n}.anchor.pt", f"amoe/moe/{n}.anchor.pt"]
245
- _AMOE_HINT = ("amoe-lora is required for arms: "
246
- "pip install git+https://github.com/AbstractEyes/amoe-lora")
247
-
248
-
249
- def _amoe():
250
- try:
251
- from amoe.core.adapter import AdapterSpec, RelayPatchwork
252
- from amoe.core.dispatch import AnchorDispatch, BlockWithDispatch
253
- from amoe.io.checkpoint import load_anchor, load_dispatch
254
- except ImportError as e:
255
- raise ImportError(_AMOE_HINT) from e
256
- return SimpleNamespace(AdapterSpec=AdapterSpec, RelayPatchwork=RelayPatchwork,
257
- AnchorDispatch=AnchorDispatch,
258
- BlockWithDispatch=BlockWithDispatch,
259
- load_anchor=load_anchor, load_dispatch=load_dispatch)
260
-
261
-
262
- class _ArmsOff:
263
- def __init__(self, model):
264
- self.m = model
265
-
266
- def __enter__(self):
267
- self._prev = [list(d.enabled) for d in self.m._amoe_dispatches]
268
- for d in self.m._amoe_dispatches:
269
- d.enabled = [False] * len(d.enabled)
270
- return self.m
271
-
272
- def __exit__(self, *exc):
273
- for d, p in zip(self.m._amoe_dispatches, self._prev):
274
- d.enabled = list(p)
275
-
276
-
277
- def _attach_amoe(self, arms=None, dispatch=AMOE_DEFAULT_DISPATCH,
278
- repo=AMOE_REPO, tau=None, verify=True):
279
- """
280
- Attach AMOE arms under a trained dispatch.
281
-
282
- arms list of arm names. None -> the dispatch checkpoint's own roster,
283
- in ITS order (the routing keys are per-arm and positional, so the
284
- order is not cosmetic).
285
- dispatch path in `repo`, a local file, or None for an untrained dispatch.
286
- verify assert that all-arms-disabled reproduces the bare trunk.
287
- """
288
- from huggingface_hub import hf_hub_download
289
- A = _amoe()
290
- if getattr(self, "_amoe_original_layers", None) is not None:
291
- raise RuntimeError("arms already attached; call detach_amoe() first")
292
-
293
- dck = None
294
- if dispatch is not None:
295
- p = dispatch if os.path.exists(dispatch) else hf_hub_download(repo, dispatch)
296
- dck = A.load_dispatch(p)
297
- if arms is None:
298
- arms = list(dck.meta.get("anchors", []))
299
- if tau is None:
300
- tau = float(dck.meta.get("tau", 0.1))
301
- if not arms:
302
- raise ValueError("no arms given and the dispatch names none")
303
- tau = 0.1 if tau is None else tau
304
-
305
- cks, resolved = [], []
306
- for name in arms:
307
- if os.path.exists(str(name)):
308
- path = str(name)
309
- else:
310
- # amoe/arms/ is the canonical library, but it is published by a
311
- # consolidation step that may not have run yet. Fall back to the
312
- # campaign folders the anchors were originally trained into, and
313
- # say which one answered.
314
- path = None
315
- for cand in ([f"{AMOE_LIB}/{name}.anchor.pt"] + AMOE_FALLBACKS(name)):
316
- try:
317
- path = hf_hub_download(repo, cand)
318
- resolved.append(cand)
319
- break
320
- except Exception:
321
- continue
322
- if path is None:
323
- raise FileNotFoundError(
324
- f"arm '{name}' not found in {repo}: tried {AMOE_LIB}/ and "
325
- f"{AMOE_FALLBACKS(name)}. Pass an explicit path, or publish "
326
- f"the arm library.")
327
- cks.append(A.load_anchor(path))
328
- if resolved and not all(r.startswith(AMOE_LIB) for r in resolved):
329
- print(f"[amoe] resolved outside {AMOE_LIB}: "
330
- f"{[r for r in resolved if not r.startswith(AMOE_LIB)]}")
331
-
332
- d = self.config.hidden_size
333
- layers = list(self.encoder.layers)
334
- self._amoe_original_layers = layers
335
- dev = next(self.parameters()).device
336
- new, disps = [], []
337
- for i, layer in enumerate(layers):
338
- stack = nn.ModuleList()
339
- for ck in cks:
340
- a = A.RelayPatchwork(d, A.AdapterSpec())
341
- a.load_state_dict({k[len(f"{i}."):]: v for k, v in ck.adapters.items()
342
- if k.startswith(f"{i}.")})
343
- for q in a.parameters():
344
- q.requires_grad_(False)
345
- stack.append(a)
346
- dp = A.AnchorDispatch(stack.to(dev), d,
347
- emb=int(dck.meta.get("emb", 64)) if dck else 64,
348
- tau=tau).to(dev)
349
- if dck is not None:
350
- with torch.no_grad():
351
- dp.dispatch.copy_(dck.dispatch[i]["dispatch"].to(dev))
352
- dp.key_proj.copy_(dck.dispatch[i]["key_proj"].to(dev))
353
- for q in dp.parameters():
354
- q.requires_grad_(False)
355
- disps.append(dp)
356
- new.append(A.BlockWithDispatch(layer, dp))
357
- self.encoder.layers = nn.ModuleList(new)
358
- self._amoe_dispatches = disps
359
- self._amoe_names = list(arms)
360
-
361
- if verify:
362
- ids = (torch.arange(8, device=dev).unsqueeze(0) % 7 + 1)
363
- am = torch.ones_like(ids)
364
- was = self.training
365
- self.eval()
366
- wrapped_layers = self.encoder.layers
367
- try:
368
- with torch.no_grad():
369
- with self.amoe_off():
370
- off = self(input_ids=ids,
371
- attention_mask=am).pooler_output.clone()
372
- # temporarily restore the bare stack for the reference forward;
373
- # try/finally so a raise here cannot strand the model holding
374
- # unwrapped layers while _amoe_dispatches still points at arms.
375
- self.encoder.layers = nn.ModuleList(layers)
376
- pure = self(input_ids=ids,
377
- attention_mask=am).pooler_output.clone()
378
- finally:
379
- self.encoder.layers = wrapped_layers
380
- if was:
381
- self.train()
382
- gap = (off - pure).abs().max().item()
383
- if gap > 1e-6:
384
- raise RuntimeError(
385
- f"TOGGLE LAW VIOLATED: arms disabled shift the trunk by {gap:.3e}. "
386
- "Do not trust any on/off comparison from this attachment.")
387
- return self
388
-
389
-
390
- def _set_amoe(self, arms):
391
- """Enable a subset by name (or a boolean mask in attach order)."""
392
- if getattr(self, "_amoe_dispatches", None) is None:
393
- raise RuntimeError("no arms attached")
394
- if arms is None:
395
- mask = [True] * len(self._amoe_names)
396
- elif all(isinstance(x, bool) for x in arms):
397
- mask = list(arms)
398
- else:
399
- unknown = [a for a in arms if a not in self._amoe_names]
400
- if unknown:
401
- raise ValueError(f"unknown arms {unknown}; have {self._amoe_names}")
402
- mask = [n in arms for n in self._amoe_names]
403
- for d in self._amoe_dispatches:
404
- d.enabled = list(mask)
405
- return self
406
-
407
-
408
- def _amoe_off(self):
409
- if getattr(self, "_amoe_dispatches", None) is None:
410
- raise RuntimeError("no arms attached")
411
- return _ArmsOff(self)
412
-
413
-
414
- def _detach_amoe(self, verify=True):
415
- """Restore the bare trunk. Bit-exact by construction; asserted by default."""
416
- orig = getattr(self, "_amoe_original_layers", None)
417
- if orig is None:
418
- return self
419
- dev = next(self.parameters()).device
420
- if verify:
421
- was = self.training
422
- self.eval()
423
- ids = (torch.arange(8, device=dev).unsqueeze(0) % 7 + 1)
424
- am = torch.ones_like(ids)
425
- with torch.no_grad():
426
- with self.amoe_off():
427
- off = self(input_ids=ids, attention_mask=am).pooler_output.clone()
428
- self.encoder.layers = nn.ModuleList(orig)
429
- with torch.no_grad():
430
- pure = self(input_ids=ids, attention_mask=am).pooler_output.clone()
431
- if was:
432
- self.train()
433
- gap = (off - pure).abs().max().item()
434
- if gap > 1e-6:
435
- raise RuntimeError(f"detach is not bit-exact: {gap:.3e}")
436
- else:
437
- self.encoder.layers = nn.ModuleList(orig)
438
- self._amoe_original_layers = None
439
- self._amoe_dispatches = None
440
- self._amoe_names = None
441
- return self
442
-
443
-
444
- def _amoe_arms(self):
445
- """Names of the attached arms, in dispatch order. Empty if none."""
446
- return list(getattr(self, "_amoe_names", None) or [])
447
-
448
-
449
- CaptionBertV2Model.attach_amoe = _attach_amoe
450
- CaptionBertV2Model.set_amoe = _set_amoe
451
- CaptionBertV2Model.amoe_off = _amoe_off
452
- CaptionBertV2Model.detach_amoe = _detach_amoe
453
- CaptionBertV2Model.amoe_arms = property(_amoe_arms)
454
-
455
- CaptionBertV2Config.register_for_auto_class()
456
- CaptionBertV2Model.register_for_auto_class("AutoModel")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================================
2
+ # modeling_captionbert.py -- AbstractPhil/captionbert-8192-v2
3
+ #
4
+ # from transformers import AutoModel, AutoTokenizer
5
+ # model = AutoModel.from_pretrained("AbstractPhil/captionbert-8192-v2",
6
+ # trust_remote_code=True)
7
+ # tok = AutoTokenizer.from_pretrained("google-bert/bert-base-uncased")
8
+ # out = model(**tok(["a cat on a windowsill"], return_tensors="pt"))
9
+ # emb = out.pooler_output # (B, 768) L2-normalized
10
+ #
11
+ # OR just: emb = model.encode(["a cat on a windowsill"])
12
+ #
13
+ # WITH AMOE ARMS (needs amoe-lora; imported lazily so the base model loads
14
+ # without it):
15
+ # model.attach_amoe() # shipped 3-arm collective
16
+ # emb = model.encode([...]) # adapted
17
+ # with model.amoe_off(): # the unsupervised baseline
18
+ # base = model.encode([...])
19
+ # model.set_amoe(["equiv"]) # one arm, DAMPED inside the dispatch
20
+ # model.detach_amoe() # bit-exact restore, asserted
21
+ #
22
+ # ---------------------------------------------------------------------------
23
+ # BREAKING CHANGE FROM v1 -- READ THIS IF YOU USED geolip-captionbert-8192
24
+ # v1 returned the POOLED 768-d embedding as `last_hidden_state`. That is not
25
+ # the transformers convention and it silently breaks anything expecting token
26
+ # states. v2 follows the convention:
27
+ # last_hidden_state : (B, L, 512) token states
28
+ # pooler_output : (B, 768) L2-normalized embedding <-- the product
29
+ # embedding : (B, 768) alias for pooler_output
30
+ # If you are porting v1 code, `last_hidden_state` -> `pooler_output`.
31
+ #
32
+ # v1 also shipped an AlignmentBank. v2 does NOT. Measured on v1: the bank's
33
+ # expert-consistency block varied 0.2% across samples and took 0.23% of its
34
+ # projection energy while anchor distances took 98.70% -- because
35
+ # `back = x @ R.T @ R` is a rotation round-trip and carries no data. Content
36
+ # extensions belong in an AMOE anchor, which this repo ships separately.
37
+ # ============================================================================
38
+
39
+ import os
40
+ from dataclasses import dataclass
41
+ from types import SimpleNamespace
42
+ from typing import List, Optional, Tuple, Union
43
+
44
+ import torch
45
+ import torch.nn as nn
46
+ import torch.nn.functional as F
47
+ from transformers import PretrainedConfig, PreTrainedModel
48
+ from transformers.modeling_outputs import BaseModelOutputWithPooling
49
+
50
+
51
+ class CaptionBertV2Config(PretrainedConfig):
52
+ model_type = "captionbert_v2"
53
+
54
+ def __init__(
55
+ self,
56
+ vocab_size: int = 30522,
57
+ hidden_size: int = 512, # d_model
58
+ num_hidden_layers: int = 12,
59
+ num_attention_heads: int = 8,
60
+ intermediate_size: int = 2048,
61
+ output_dim: int = 768, # consensus space
62
+ max_position_embeddings: int = 8192,
63
+ hidden_dropout_prob: float = 0.1,
64
+ pad_token_id: int = 0,
65
+ pooling: str = "mean", # "mean" | "cls"
66
+ **kwargs,
67
+ ):
68
+ super().__init__(pad_token_id=pad_token_id, **kwargs)
69
+ self.vocab_size = vocab_size
70
+ self.hidden_size = hidden_size
71
+ self.num_hidden_layers = num_hidden_layers
72
+ self.num_attention_heads = num_attention_heads
73
+ self.intermediate_size = intermediate_size
74
+ self.output_dim = output_dim
75
+ self.max_position_embeddings = max_position_embeddings
76
+ self.hidden_dropout_prob = hidden_dropout_prob
77
+ self.pooling = pooling
78
+
79
+
80
+ class CaptionBertV2Model(PreTrainedModel):
81
+ """
82
+ Standalone caption/sentence encoder distilled from the geometric consensus
83
+ of five BERT-family teachers. No expert models at inference.
84
+
85
+ Parameter names are deliberately NOT namespaced under a submodule so that
86
+ the training checkpoint loads unchanged: token_emb, pos_emb, emb_norm,
87
+ encoder.layers.*, output_proj.*.
88
+ """
89
+
90
+ config_class = CaptionBertV2Config
91
+ base_model_prefix = "captionbert_v2"
92
+ supports_gradient_checkpointing = True
93
+
94
+ def __init__(self, config: CaptionBertV2Config):
95
+ super().__init__(config)
96
+ d = config.hidden_size
97
+ self.token_emb = nn.Embedding(config.vocab_size, d,
98
+ padding_idx=config.pad_token_id)
99
+ self.pos_emb = nn.Embedding(config.max_position_embeddings, d)
100
+ self.emb_norm = nn.LayerNorm(d)
101
+ self.emb_drop = nn.Dropout(config.hidden_dropout_prob)
102
+ layer = nn.TransformerEncoderLayer(
103
+ d_model=d,
104
+ nhead=config.num_attention_heads,
105
+ dim_feedforward=config.intermediate_size,
106
+ dropout=config.hidden_dropout_prob,
107
+ activation="gelu",
108
+ batch_first=True,
109
+ norm_first=True,
110
+ )
111
+ self.encoder = nn.TransformerEncoder(
112
+ layer, num_layers=config.num_hidden_layers, enable_nested_tensor=False)
113
+ self.output_proj = nn.Sequential(
114
+ nn.Linear(d, d), nn.GELU(), nn.LayerNorm(d), nn.Linear(d, config.output_dim))
115
+ self.post_init()
116
+
117
+ # -- HF plumbing --
118
+ def get_input_embeddings(self):
119
+ return self.token_emb
120
+
121
+ def set_input_embeddings(self, value):
122
+ self.token_emb = value
123
+
124
+ def forward(
125
+ self,
126
+ input_ids: torch.LongTensor = None,
127
+ attention_mask: Optional[torch.Tensor] = None,
128
+ output_hidden_states: Optional[bool] = None,
129
+ return_dict: Optional[bool] = None,
130
+ **kwargs,
131
+ ) -> Union[Tuple, BaseModelOutputWithPooling]:
132
+ return_dict = return_dict if return_dict is not None else True
133
+ L = input_ids.shape[1]
134
+ pos = torch.arange(L, device=input_ids.device).unsqueeze(0)
135
+ x = self.emb_drop(self.emb_norm(self.token_emb(input_ids) + self.pos_emb(pos)))
136
+
137
+ kpm = (~attention_mask.bool()) if attention_mask is not None \
138
+ else (input_ids == self.config.pad_token_id)
139
+
140
+ hidden = [x] if output_hidden_states else None
141
+ # Iterate the layers directly rather than calling self.encoder(...):
142
+ # nn.TransformerEncoder's fast path inspects layer types, and an AMOE
143
+ # anchor wraps each layer in a BlockWithAdapter that is not a
144
+ # TransformerEncoderLayer. This keeps attach() a drop-in.
145
+ for mod in self.encoder.layers:
146
+ x = mod(x, src_key_padding_mask=kpm)
147
+ if output_hidden_states:
148
+ hidden.append(x)
149
+ if self.encoder.norm is not None:
150
+ x = self.encoder.norm(x)
151
+
152
+ if self.config.pooling == "cls":
153
+ pooled = x[:, 0]
154
+ else:
155
+ m = (attention_mask.unsqueeze(-1).to(x.dtype) if attention_mask is not None
156
+ else (~kpm).unsqueeze(-1).to(x.dtype))
157
+ pooled = (x * m).sum(1) / m.sum(1).clamp(min=1)
158
+ embedding = F.normalize(self.output_proj(pooled), dim=-1)
159
+
160
+ if not return_dict:
161
+ return (x, embedding) + ((tuple(hidden),) if output_hidden_states else ())
162
+ out = BaseModelOutputWithPooling(
163
+ last_hidden_state=x, # (B, L, 512) token states
164
+ pooler_output=embedding, # (B, 768) THE PRODUCT
165
+ hidden_states=tuple(hidden) if output_hidden_states else None,
166
+ )
167
+ out.embedding = embedding # explicit alias
168
+ return out
169
+
170
+ @torch.no_grad()
171
+ def encode(self, texts, tokenizer=None, batch_size: int = 128,
172
+ max_length: int = 256, device=None) -> torch.Tensor:
173
+ """Raw text -> (N, 768) L2-normalized embeddings."""
174
+ if isinstance(texts, str):
175
+ texts = [texts]
176
+ if tokenizer is None:
177
+ from transformers import AutoTokenizer
178
+ tokenizer = AutoTokenizer.from_pretrained("google-bert/bert-base-uncased")
179
+ device = device or next(self.parameters()).device
180
+ was_training = self.training
181
+ self.eval()
182
+ out = []
183
+ for i in range(0, len(texts), batch_size):
184
+ t = tokenizer(list(texts[i:i + batch_size]), max_length=max_length,
185
+ padding=True, truncation=True, return_tensors="pt").to(device)
186
+ out.append(self(**t).pooler_output.float().cpu())
187
+ if was_training:
188
+ self.train()
189
+ return torch.cat(out)
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # AMOE binding -- lets amoe-lora attach anchors to this trunk unmodified.
194
+ #
195
+ # import amoe
196
+ # from modeling_captionbert import CaptionBertV2Binding
197
+ # h = amoe.attach(model, "amoe/moe/equiv.anchor.pt",
198
+ # binding=CaptionBertV2Binding(d=model.config.hidden_size))
199
+ #
200
+ # amoe's PathBinding would find encoder.layers by dotted path but then read
201
+ # model.config.hidden_size -- which works here because this IS a
202
+ # PretrainedConfig. The explicit binding is kept for plain-nn.Module use.
203
+ # ---------------------------------------------------------------------------
204
+
205
+ @dataclass
206
+ class CaptionBertV2Binding:
207
+ d: int = 512
208
+ name: str = "captionbert_v2"
209
+
210
+ def layers(self, model):
211
+ return model.encoder.layers
212
+
213
+ def set_layers(self, model, new):
214
+ model.encoder.layers = nn.ModuleList(new)
215
+
216
+ def hidden_size(self, model) -> int:
217
+ return int(self.d)
218
+
219
+
220
+ # ---------------------------------------------------------------------------
221
+ # AMOE ARMS -- attach / toggle / detach, from the same AutoModel object.
222
+ #
223
+ # model = AutoModel.from_pretrained(REPO, trust_remote_code=True)
224
+ # model.attach_amoe() # shipped 3-arm collective
225
+ # emb = model.encode(["a cat on a windowsill"])
226
+ # with model.amoe_off(): # the unsupervised baseline
227
+ # base = model.encode(["a cat on a windowsill"])
228
+ # model.set_amoe(["equiv"]) # one arm inside the dispatch
229
+ # model.detach_amoe() # bit-exact restore, asserted
230
+ #
231
+ # `amoe` is imported LAZILY: this file is executed by every
232
+ # AutoModel.from_pretrained(trust_remote_code=True), and the base model must
233
+ # load on a machine that has never heard of amoe-lora.
234
+ #
235
+ # Masking never renormalizes (the damping law), so a single arm inside the
236
+ # dispatch is DAMPED and reads lower than that arm trained alone. That is the
237
+ # mechanism, not a bug -- see the model card.
238
+ # ---------------------------------------------------------------------------
239
+
240
+ # AMOE_REPO defaults to THE REPO THIS MODEL WAS LOADED FROM, not a hardcoded
241
+ # name. Anchors are TRUNK-BOUND: measured 2026-08-02, the captionbert-8192-v2
242
+ # arms lose 31% of their gain on the -B trunk (.7287 -> .6863), and re-aligning
243
+ # the routing keys alone recovers only 29% of that. Retrained natively they
244
+ # reach .7294. So a hardcoded arms repo silently hands a trunk the wrong arms.
245
+ AMOE_REPO = None # None -> config._name_or_path
246
+ AMOE_LIB = "amoe/arms"
247
+ # dispatch checkpoints are searched in order; the first that exists wins
248
+ AMOE_DISPATCH_CANDIDATES = (
249
+ "amoe/b-collective/captionbert-b-arms-native.dispatch.pt",
250
+ "amoe/collective/captionbert-v2-collective.dispatch.pt",
251
+ )
252
+ AMOE_DEFAULT_DISPATCH = None # None -> first candidate present in the repo
253
+ AMOE_FALLBACKS = lambda n: [f"amoe/b-collective/{n}.anchor.pt",
254
+ f"amoe/collective/{n}.anchor.pt",
255
+ f"amoe/moe/{n}.anchor.pt"]
256
+ _AMOE_HINT = ("amoe-lora is required for arms: "
257
+ "pip install git+https://github.com/AbstractEyes/amoe-lora")
258
+
259
+
260
+ def _amoe():
261
+ try:
262
+ from amoe.core.adapter import AdapterSpec, RelayPatchwork
263
+ from amoe.core.dispatch import AnchorDispatch, BlockWithDispatch
264
+ from amoe.io.checkpoint import load_anchor, load_dispatch
265
+ except ImportError as e:
266
+ raise ImportError(_AMOE_HINT) from e
267
+ return SimpleNamespace(AdapterSpec=AdapterSpec, RelayPatchwork=RelayPatchwork,
268
+ AnchorDispatch=AnchorDispatch,
269
+ BlockWithDispatch=BlockWithDispatch,
270
+ load_anchor=load_anchor, load_dispatch=load_dispatch)
271
+
272
+
273
+ class _ArmsOff:
274
+ def __init__(self, model):
275
+ self.m = model
276
+
277
+ def __enter__(self):
278
+ self._prev = [list(d.enabled) for d in self.m._amoe_dispatches]
279
+ for d in self.m._amoe_dispatches:
280
+ d.enabled = [False] * len(d.enabled)
281
+ return self.m
282
+
283
+ def __exit__(self, *exc):
284
+ for d, p in zip(self.m._amoe_dispatches, self._prev):
285
+ d.enabled = list(p)
286
+
287
+
288
+ def _attach_amoe(self, arms=None, dispatch=None, repo=None, tau=None, verify=True):
289
+ """
290
+ Attach AMOE arms under a trained dispatch.
291
+
292
+ arms list of arm names. None -> the dispatch checkpoint's own roster,
293
+ in ITS order (the routing keys are per-arm and positional, so the
294
+ order is not cosmetic).
295
+ dispatch path in `repo`, a local file, or None for an untrained dispatch.
296
+ verify assert that all-arms-disabled reproduces the bare trunk.
297
+ """
298
+ from huggingface_hub import hf_hub_download, HfApi
299
+ A = _amoe()
300
+ if getattr(self, "_amoe_original_layers", None) is not None:
301
+ raise RuntimeError("arms already attached; call detach_amoe() first")
302
+
303
+ # resolve the repo: this model's own, unless told otherwise
304
+ if repo is None:
305
+ repo = AMOE_REPO or getattr(self.config, "_name_or_path", None)
306
+ if not repo:
307
+ raise ValueError("cannot determine the arms repo; pass repo=...")
308
+ # resolve the dispatch: first candidate actually present in THAT repo
309
+ if dispatch is None:
310
+ dispatch = AMOE_DEFAULT_DISPATCH
311
+ if dispatch is None:
312
+ try:
313
+ present = set(HfApi().list_repo_files(repo))
314
+ except Exception:
315
+ present = set()
316
+ dispatch = next((c for c in AMOE_DISPATCH_CANDIDATES if c in present), None)
317
+ if dispatch is None:
318
+ local = os.path.isdir(str(repo))
319
+ hint = (f"'{repo}' is a LOCAL DIRECTORY, so there is no hub repo to "
320
+ f"search. Pass the repo explicitly:\n"
321
+ f" model.attach_amoe(repo='AbstractPhil/captionbert-8192-v2-B')"
322
+ if local else
323
+ f"Pass dispatch=<path in {repo}>, or dispatch=False to attach "
324
+ f"an UNTRAINED dispatch.")
325
+ raise FileNotFoundError(
326
+ f"no dispatch found in {repo}. Tried "
327
+ f"{list(AMOE_DISPATCH_CANDIDATES)}.\n {hint}")
328
+ print(f"[amoe] dispatch: {repo}/{dispatch}")
329
+ if dispatch is False:
330
+ dispatch = None
331
+
332
+ dck = None
333
+ if dispatch is not None:
334
+ p = dispatch if os.path.exists(dispatch) else hf_hub_download(repo, dispatch)
335
+ dck = A.load_dispatch(p)
336
+ if arms is None:
337
+ arms = list(dck.meta.get("anchors", []))
338
+ if tau is None:
339
+ tau = float(dck.meta.get("tau", 0.1))
340
+ if not arms:
341
+ raise ValueError("no arms given and the dispatch names none")
342
+ tau = 0.1 if tau is None else tau
343
+
344
+ cks, resolved = [], []
345
+ for name in arms:
346
+ if os.path.exists(str(name)):
347
+ path = str(name)
348
+ else:
349
+ # amoe/arms/ is the canonical library, but it is published by a
350
+ # consolidation step that may not have run yet. Fall back to the
351
+ # campaign folders the anchors were originally trained into, and
352
+ # say which one answered.
353
+ path = None
354
+ for cand in ([f"{AMOE_LIB}/{name}.anchor.pt"] + AMOE_FALLBACKS(name)):
355
+ try:
356
+ path = hf_hub_download(repo, cand)
357
+ resolved.append(cand)
358
+ break
359
+ except Exception:
360
+ continue
361
+ if path is None:
362
+ raise FileNotFoundError(
363
+ f"arm '{name}' not found in {repo}: tried {AMOE_LIB}/ and "
364
+ f"{AMOE_FALLBACKS(name)}. Pass an explicit path, or publish "
365
+ f"the arm library.")
366
+ cks.append(A.load_anchor(path))
367
+ if resolved and not all(r.startswith(AMOE_LIB) for r in resolved):
368
+ print(f"[amoe] resolved outside {AMOE_LIB}: "
369
+ f"{[r for r in resolved if not r.startswith(AMOE_LIB)]}")
370
+
371
+ d = self.config.hidden_size
372
+ layers = list(self.encoder.layers)
373
+ self._amoe_original_layers = layers
374
+ dev = next(self.parameters()).device
375
+ new, disps = [], []
376
+ for i, layer in enumerate(layers):
377
+ stack = nn.ModuleList()
378
+ for ck in cks:
379
+ a = A.RelayPatchwork(d, A.AdapterSpec())
380
+ a.load_state_dict({k[len(f"{i}."):]: v for k, v in ck.adapters.items()
381
+ if k.startswith(f"{i}.")})
382
+ for q in a.parameters():
383
+ q.requires_grad_(False)
384
+ stack.append(a)
385
+ dp = A.AnchorDispatch(stack.to(dev), d,
386
+ emb=int(dck.meta.get("emb", 64)) if dck else 64,
387
+ tau=tau).to(dev)
388
+ if dck is not None:
389
+ with torch.no_grad():
390
+ dp.dispatch.copy_(dck.dispatch[i]["dispatch"].to(dev))
391
+ dp.key_proj.copy_(dck.dispatch[i]["key_proj"].to(dev))
392
+ for q in dp.parameters():
393
+ q.requires_grad_(False)
394
+ disps.append(dp)
395
+ new.append(A.BlockWithDispatch(layer, dp))
396
+ self.encoder.layers = nn.ModuleList(new)
397
+ self._amoe_dispatches = disps
398
+ self._amoe_names = list(arms)
399
+
400
+ if verify:
401
+ ids = (torch.arange(8, device=dev).unsqueeze(0) % 7 + 1)
402
+ am = torch.ones_like(ids)
403
+ was = self.training
404
+ self.eval()
405
+ wrapped_layers = self.encoder.layers
406
+ try:
407
+ with torch.no_grad():
408
+ with self.amoe_off():
409
+ off = self(input_ids=ids,
410
+ attention_mask=am).pooler_output.clone()
411
+ # temporarily restore the bare stack for the reference forward;
412
+ # try/finally so a raise here cannot strand the model holding
413
+ # unwrapped layers while _amoe_dispatches still points at arms.
414
+ self.encoder.layers = nn.ModuleList(layers)
415
+ pure = self(input_ids=ids,
416
+ attention_mask=am).pooler_output.clone()
417
+ finally:
418
+ self.encoder.layers = wrapped_layers
419
+ if was:
420
+ self.train()
421
+ gap = (off - pure).abs().max().item()
422
+ if gap > 1e-6:
423
+ raise RuntimeError(
424
+ f"TOGGLE LAW VIOLATED: arms disabled shift the trunk by {gap:.3e}. "
425
+ "Do not trust any on/off comparison from this attachment.")
426
+ return self
427
+
428
+
429
+ def _set_amoe(self, arms):
430
+ """Enable a subset by name (or a boolean mask in attach order)."""
431
+ if getattr(self, "_amoe_dispatches", None) is None:
432
+ raise RuntimeError("no arms attached")
433
+ if arms is None:
434
+ mask = [True] * len(self._amoe_names)
435
+ elif all(isinstance(x, bool) for x in arms):
436
+ mask = list(arms)
437
+ else:
438
+ unknown = [a for a in arms if a not in self._amoe_names]
439
+ if unknown:
440
+ raise ValueError(f"unknown arms {unknown}; have {self._amoe_names}")
441
+ mask = [n in arms for n in self._amoe_names]
442
+ for d in self._amoe_dispatches:
443
+ d.enabled = list(mask)
444
+ return self
445
+
446
+
447
+ def _amoe_off(self):
448
+ if getattr(self, "_amoe_dispatches", None) is None:
449
+ raise RuntimeError("no arms attached")
450
+ return _ArmsOff(self)
451
+
452
+
453
+ def _detach_amoe(self, verify=True):
454
+ """Restore the bare trunk. Bit-exact by construction; asserted by default."""
455
+ orig = getattr(self, "_amoe_original_layers", None)
456
+ if orig is None:
457
+ return self
458
+ dev = next(self.parameters()).device
459
+ if verify:
460
+ was = self.training
461
+ self.eval()
462
+ ids = (torch.arange(8, device=dev).unsqueeze(0) % 7 + 1)
463
+ am = torch.ones_like(ids)
464
+ with torch.no_grad():
465
+ with self.amoe_off():
466
+ off = self(input_ids=ids, attention_mask=am).pooler_output.clone()
467
+ self.encoder.layers = nn.ModuleList(orig)
468
+ with torch.no_grad():
469
+ pure = self(input_ids=ids, attention_mask=am).pooler_output.clone()
470
+ if was:
471
+ self.train()
472
+ gap = (off - pure).abs().max().item()
473
+ if gap > 1e-6:
474
+ raise RuntimeError(f"detach is not bit-exact: {gap:.3e}")
475
+ else:
476
+ self.encoder.layers = nn.ModuleList(orig)
477
+ self._amoe_original_layers = None
478
+ self._amoe_dispatches = None
479
+ self._amoe_names = None
480
+ return self
481
+
482
+
483
+ def _amoe_arms(self):
484
+ """Names of the attached arms, in dispatch order. Empty if none."""
485
+ return list(getattr(self, "_amoe_names", None) or [])
486
+
487
+
488
+ CaptionBertV2Model.attach_amoe = _attach_amoe
489
+ CaptionBertV2Model.set_amoe = _set_amoe
490
+ CaptionBertV2Model.amoe_off = _amoe_off
491
+ CaptionBertV2Model.detach_amoe = _detach_amoe
492
+ CaptionBertV2Model.amoe_arms = property(_amoe_arms)
493
+
494
+ CaptionBertV2Config.register_for_auto_class()
495
+ CaptionBertV2Model.register_for_auto_class("AutoModel")