Update model card and license

#1
by iamleonie - opened
config.json CHANGED
@@ -3,8 +3,8 @@
3
  "Lfm2BidirForRuleMatching"
4
  ],
5
  "auto_map": {
6
- "AutoModel": "modeling_bizlint_rule_matching.Lfm2BidirForRuleMatching",
7
- "AutoModelForMaskedLM": "modeling_lfm2_bidirectional.Lfm2BidirForMaskedLM"
8
  },
9
  "block_auto_adjust_ff_dim": true,
10
  "block_dim": 1024,
@@ -62,4 +62,4 @@
62
  "use_cache": false,
63
  "use_pos_enc": true,
64
  "vocab_size": 65536
65
- }
 
3
  "Lfm2BidirForRuleMatching"
4
  ],
5
  "auto_map": {
6
+ "AutoModel": "modeling_lfm2_bidir_theirs.Lfm2BidirectionalModel_theirs",
7
+ "AutoModelForMaskedLM": "modeling_lfm2_bidir_theirs.Lfm2BidirForMaskedLM_theirs"
8
  },
9
  "block_auto_adjust_ff_dim": true,
10
  "block_dim": 1024,
 
62
  "use_cache": false,
63
  "use_pos_enc": true,
64
  "vocab_size": 65536
65
+ }
modeling_bizlint_rule_matching.py DELETED
@@ -1,55 +0,0 @@
1
- """Self-contained inference class for the bizlint GLiNER-style rule-matching
2
- head — mirrors the training-time class so trust_remote_code loading restores
3
- every checkpoint weight (backbone under `lfm2.*` + tok_proj/rule_proj/
4
- score_bias head).
5
-
6
- score[t, r] = <P_tok(h_t), P_rule(mean(h[rule_r tokens]))> / sqrt(d) + b
7
-
8
- Input format: "Policy:\n- <rule 1>\n- <rule 2>\n\nText:\n<doc>"
9
- `rule_pool` is a (B, R, T) matrix of normalized pooling weights over each
10
- rule's token range; sigmoid(score) > 0.5 flags a (token, rule) match.
11
- """
12
- import torch
13
- import torch.nn as nn
14
- from transformers.models.lfm2.configuration_lfm2 import Lfm2Config
15
- from transformers.models.lfm2.modeling_lfm2 import Lfm2PreTrainedModel
16
-
17
- from .modeling_lfm2_bidirectional import Lfm2BidirectionalModel
18
-
19
- PROJ_D = 256
20
-
21
-
22
- class Lfm2BidirForRuleMatching(Lfm2PreTrainedModel):
23
- config_class = Lfm2Config
24
- base_model_prefix = "lfm2"
25
-
26
- def __init__(self, config):
27
- super().__init__(config)
28
- self.lfm2 = Lfm2BidirectionalModel(config)
29
- d = getattr(config, "rule_proj_dim", PROJ_D)
30
- self.tok_proj = nn.Linear(config.hidden_size, d)
31
- self.rule_proj = nn.Linear(config.hidden_size, d)
32
- self.score_bias = nn.Parameter(torch.tensor(-2.0))
33
- self.post_init()
34
-
35
- def forward(self, input_ids=None, attention_mask=None, rule_pool=None,
36
- labels=None, label_mask=None, **kw):
37
- h = self.lfm2(input_ids=input_ids, attention_mask=attention_mask,
38
- use_cache=False, return_dict=True).last_hidden_state
39
- if rule_pool is None:
40
- return {"loss": None, "logits": None, "last_hidden_state": h}
41
- rule_rep = torch.bmm(rule_pool, h)
42
- tp = self.tok_proj(h)
43
- rp = self.rule_proj(rule_rep)
44
- scores = torch.einsum("btd,brd->btr", tp, rp) / (tp.shape[-1] ** 0.5) \
45
- + self.score_bias
46
- loss = None
47
- if labels is not None:
48
- m = label_mask.bool()
49
- if m.any():
50
- loss = nn.functional.binary_cross_entropy_with_logits(
51
- scores[m], labels[m],
52
- pos_weight=torch.tensor(8.0, device=scores.device))
53
- else:
54
- loss = scores.sum() * 0.0
55
- return {"loss": loss, "logits": scores, "last_hidden_state": h}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
modeling_lfm2_bidirectional.py → modeling_lfm2_bidir_theirs.py RENAMED
@@ -1,21 +1,32 @@
1
- """LFM2 backbone with bidirectional attention + non-causal short-conv.
2
-
3
- Wired into the HF repo via `auto_map` in config.json so that
4
-
5
- AutoModel.from_pretrained(repo, trust_remote_code=True)
6
- AutoModelForMaskedLM.from_pretrained(repo, trust_remote_code=True)
7
-
8
- both return a model with the encoder-style patches already applied.
9
-
10
- Supports `attn_implementation` in {"eager", "sdpa", "flash_attention_2"}:
11
-
12
- eager/sdpa consume a 4D additive pad-only mask and reproduce the exact
13
- training-time behavior; flash_attention_2 receives the 2D padding mask (or
14
- None) and runs the kernel non-causally via `Lfm2Attention.is_causal = False`,
15
- yielding outputs equivalent to the unpadded forward.
 
 
 
 
 
 
 
 
 
 
 
 
16
  """
17
 
18
- import math
19
  from typing import Optional
20
 
21
  import torch
@@ -35,6 +46,9 @@ from transformers.models.lfm2.modeling_lfm2 import (
35
  )
36
 
37
 
 
 
 
38
  def _bidirectional_mask(
39
  config,
40
  input_embeds: torch.Tensor = None,
@@ -44,14 +58,13 @@ def _bidirectional_mask(
44
  position_ids: Optional[torch.LongTensor] = None,
45
  **kwargs,
46
  ) -> Optional[torch.Tensor]:
47
- # transformers has renamed the embeds kwarg across versions
48
- # (input_embeds <-> inputs_embeds); accept either to stay forward-compatible.
49
  if input_embeds is None:
50
  input_embeds = kwargs.get("inputs_embeds")
51
 
52
  if config._attn_implementation == "flash_attention_2":
53
- # FA2 only uses the 2D padding mask to unpad sequences; causality is
54
- # controlled by `Lfm2Attention.is_causal` (set to False below).
55
  if attention_mask is not None and not attention_mask.all():
56
  return attention_mask
57
  return None
@@ -73,6 +86,9 @@ def _bidirectional_mask(
73
  return mask
74
 
75
 
 
 
 
76
  def _noncausal_shortconv_forward(
77
  self,
78
  hidden_states: torch.Tensor,
@@ -129,9 +145,12 @@ def _set_attention_noncausal(model) -> None:
129
  module.is_causal = False
130
 
131
 
132
- class Lfm2BidirectionalModel(Lfm2Model):
133
- """LFM2 patched for encoder-style use:
134
- full bidirectional attention + non-causal short-conv."""
 
 
 
135
 
136
  def __init__(self, config):
137
  _install_patches()
@@ -139,8 +158,11 @@ class Lfm2BidirectionalModel(Lfm2Model):
139
  _set_attention_noncausal(self)
140
 
141
 
142
- class Lfm2BidirectionalForMaskedLM(Lfm2PreTrainedModel):
143
- """LFM2 bidirectional encoder with a tied masked-LM head."""
 
 
 
144
 
145
  config_class = Lfm2Config
146
  base_model_prefix = "lfm2"
@@ -148,11 +170,13 @@ class Lfm2BidirectionalForMaskedLM(Lfm2PreTrainedModel):
148
 
149
  def __init__(self, config: Lfm2Config):
150
  _install_patches()
 
151
  config = type(config).from_dict({**config.to_dict(), "use_cache": False})
152
  super().__init__(config)
153
- self.lfm2 = Lfm2BidirectionalModel(config)
154
  self.lm_head = nn.Linear(config.hidden_size, config.vocab_size, bias=False)
155
  self.post_init()
 
156
  self.lm_head.weight = self.lfm2.embed_tokens.weight
157
 
158
  def get_input_embeddings(self):
@@ -210,102 +234,3 @@ class Lfm2BidirectionalForMaskedLM(Lfm2PreTrainedModel):
210
  hidden_states=outputs.hidden_states,
211
  attentions=outputs.attentions,
212
  )
213
-
214
-
215
- class Lfm2BidirForSequenceRouting(Lfm2PreTrainedModel):
216
- """Zero-shot prompt router built on the bidirectional LFM2 encoder."""
217
-
218
- config_class = Lfm2Config
219
- base_model_prefix = "lfm2"
220
-
221
- def __init__(self, config: Lfm2Config):
222
- _install_patches()
223
- config = type(config).from_dict({**config.to_dict(), "use_cache": False})
224
- super().__init__(config)
225
- self.lfm2 = Lfm2BidirectionalModel(config)
226
- proj_dim = getattr(config, "rule_proj_dim", 256)
227
- self.tok_proj = nn.Linear(config.hidden_size, proj_dim)
228
- self.rule_proj = nn.Linear(config.hidden_size, proj_dim)
229
- self.score_bias = nn.Parameter(torch.tensor(0.0))
230
- self.logit_scale = nn.Parameter(torch.tensor(1.0))
231
- self.post_init()
232
-
233
- def get_input_embeddings(self):
234
- return self.lfm2.embed_tokens
235
-
236
- def set_input_embeddings(self, value):
237
- self.lfm2.embed_tokens = value
238
-
239
- def forward(
240
- self,
241
- input_ids: Optional[torch.LongTensor] = None,
242
- attention_mask: Optional[torch.Tensor] = None,
243
- text_pool: Optional[torch.Tensor] = None,
244
- category_pool: Optional[torch.Tensor] = None,
245
- **kwargs,
246
- ):
247
- outputs = self.lfm2(
248
- input_ids=input_ids,
249
- attention_mask=attention_mask,
250
- use_cache=False,
251
- return_dict=True,
252
- )
253
- hidden = outputs.last_hidden_state
254
- text_rep = torch.bmm(text_pool, hidden).squeeze(1)
255
- category_rep = torch.bmm(category_pool, hidden)
256
- query = F.normalize(self.tok_proj(text_rep), dim=-1)
257
- categories = F.normalize(self.rule_proj(category_rep), dim=-1)
258
- scale = torch.clamp(self.logit_scale.exp(), max=30.0)
259
- logits = torch.einsum("bd,brd->br", query, categories) * scale + self.score_bias
260
- return {"logits": logits}
261
-
262
- @staticmethod
263
- def _prefix(routes):
264
- body = "\n".join(f"- {route}" for route in routes) if routes else "- (none)"
265
- return f"Categories:\n{body}\n\nText:\n"
266
-
267
- @staticmethod
268
- def _category_ranges(routes):
269
- ranges = []
270
- pos = len("Categories:\n")
271
- for route in routes:
272
- start = pos + 2
273
- end = start + len(route)
274
- ranges.append((start, end))
275
- pos = end + 1
276
- return ranges
277
-
278
- @torch.no_grad()
279
- def route(self, text, routes, tokenizer, threshold=None):
280
- prefix = self._prefix(routes)
281
- full_text = prefix + text
282
- enc = tokenizer(full_text, return_offsets_mapping=True, return_tensors="pt")
283
- offsets = enc.pop("offset_mapping")[0].tolist()
284
- enc = {k: v.to(self.device) for k, v in enc.items()}
285
-
286
- text_start = len(prefix)
287
- text_idxs = [
288
- i for i, (start, end) in enumerate(offsets)
289
- if end > text_start and start != end
290
- ]
291
- text_pool = torch.zeros(1, 1, len(offsets), device=self.device)
292
- if text_idxs:
293
- text_pool[0, 0, text_idxs] = 1 / len(text_idxs)
294
-
295
- category_pool = torch.zeros(1, len(routes), len(offsets), device=self.device)
296
- for route_idx, (start, end) in enumerate(self._category_ranges(routes)):
297
- token_idxs = [
298
- i for i, (tok_start, tok_end) in enumerate(offsets)
299
- if tok_start < end and tok_end > start and tok_start != tok_end
300
- ]
301
- if token_idxs:
302
- category_pool[0, route_idx, token_idxs] = 1 / len(token_idxs)
303
-
304
- logits = self(**enc, text_pool=text_pool, category_pool=category_pool)["logits"][0]
305
- probs = logits.softmax(dim=-1).detach().cpu()
306
- results = [
307
- {"route": route, "score": float(prob)}
308
- for route, prob in zip(routes, probs)
309
- if threshold is None or prob >= threshold
310
- ]
311
- return sorted(results, key=lambda item: item["score"], reverse=True)
 
1
+ """
2
+ Self-contained inference modeling for the Lfm2 bidirectional encoder MLM
3
+ (BiEnc-preview shortconv variant, aka "bidirectional-2-exp" / mlm-bidir2).
4
+
5
+ Designed to be SHIPPED ALONGSIDE THE CHECKPOINT via `trust_remote_code`:
6
+
7
+ >>> from transformers import AutoModelForMaskedLM, AutoTokenizer, AutoModel
8
+ >>> tok = AutoTokenizer.from_pretrained("LiquidAI/mlm-bidir2", trust_remote_code=True)
9
+ >>> mlm = AutoModelForMaskedLM.from_pretrained("LiquidAI/mlm-bidir2", trust_remote_code=True)
10
+ >>> body = AutoModel.from_pretrained("LiquidAI/mlm-bidir2", trust_remote_code=True)
11
+
12
+ This file:
13
+ 1. Installs the BiEnc-preview-style bidirectional patches:
14
+ * `create_causal_mask` -> non-causal padding-only additive mask (with an
15
+ FA2 path that returns the 2D padding mask)
16
+ * `Lfm2ShortConv.forward` -> full pipeline:
17
+ in_proj -> chunk(B,C,x) -> B*x -> conv1d(symmetric pad) -> C*conv_out -> out_proj
18
+ (UNLIKE the "bidirectional-1-exp" variant which used depthwise-only conv1d
19
+ on hidden_states.)
20
+ 2. Exposes `Lfm2BidirectionalModel_theirs(Lfm2Model)` — the encoder base, with
21
+ `Lfm2Attention.is_causal = False`.
22
+ 3. Exposes `Lfm2BidirForMaskedLM_theirs(Lfm2PreTrainedModel)` — adds an MLM
23
+ head tied to `embed_tokens.weight`.
24
+
25
+ Compatible with `transformers >= 5.0`. AutoModelForMaskedLM dispatches via the
26
+ `auto_map` in config.json. The forward signature absorbs kwargs to stay
27
+ compatible across upstream signature drift between transformers minor versions.
28
  """
29
 
 
30
  from typing import Optional
31
 
32
  import torch
 
46
  )
47
 
48
 
49
+ # --------------------------------------------------------------------------- #
50
+ # Patch 1: bidirectional attention mask #
51
+ # --------------------------------------------------------------------------- #
52
  def _bidirectional_mask(
53
  config,
54
  input_embeds: torch.Tensor = None,
 
58
  position_ids: Optional[torch.LongTensor] = None,
59
  **kwargs,
60
  ) -> Optional[torch.Tensor]:
61
+ # transformers 5.x renamed input_embeds -> inputs_embeds; absorb both.
 
62
  if input_embeds is None:
63
  input_embeds = kwargs.get("inputs_embeds")
64
 
65
  if config._attn_implementation == "flash_attention_2":
66
+ # FA2 only consumes the 2D padding mask to unpad; causality is
67
+ # controlled by Lfm2Attention.is_causal = False (set below).
68
  if attention_mask is not None and not attention_mask.all():
69
  return attention_mask
70
  return None
 
86
  return mask
87
 
88
 
89
+ # --------------------------------------------------------------------------- #
90
+ # Patch 2: BiEnc-preview shortconv forward (full pipeline) #
91
+ # --------------------------------------------------------------------------- #
92
  def _noncausal_shortconv_forward(
93
  self,
94
  hidden_states: torch.Tensor,
 
145
  module.is_causal = False
146
 
147
 
148
+ # --------------------------------------------------------------------------- #
149
+ # Base model Lfm2 backbone, encoder-style #
150
+ # --------------------------------------------------------------------------- #
151
+ class Lfm2BidirectionalModel_theirs(Lfm2Model):
152
+ """LFM2 backbone patched for encoder-style use:
153
+ full bidirectional attention + BiEnc-preview non-causal short-conv."""
154
 
155
  def __init__(self, config):
156
  _install_patches()
 
158
  _set_attention_noncausal(self)
159
 
160
 
161
+ # --------------------------------------------------------------------------- #
162
+ # AutoModelForMaskedLM head #
163
+ # --------------------------------------------------------------------------- #
164
+ class Lfm2BidirForMaskedLM_theirs(Lfm2PreTrainedModel):
165
+ """Lfm2 bidirectional encoder + MLM head (tied to embed_tokens.weight)."""
166
 
167
  config_class = Lfm2Config
168
  base_model_prefix = "lfm2"
 
170
 
171
  def __init__(self, config: Lfm2Config):
172
  _install_patches()
173
+ # MLM never uses KV cache
174
  config = type(config).from_dict({**config.to_dict(), "use_cache": False})
175
  super().__init__(config)
176
+ self.lfm2 = Lfm2BidirectionalModel_theirs(config)
177
  self.lm_head = nn.Linear(config.hidden_size, config.vocab_size, bias=False)
178
  self.post_init()
179
+ # tie weights
180
  self.lm_head.weight = self.lfm2.embed_tokens.weight
181
 
182
  def get_input_embeddings(self):
 
234
  hidden_states=outputs.hidden_states,
235
  attentions=outputs.attentions,
236
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
train_bizlint_v02.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """bizlint v02 — GLiNER-style rule matching on the LFM2.5 bidirectional encoder.
3
+
4
+ No fixed classes: each policy RULE in the input is a label. Rule representations
5
+ are mean-pooled from the same forward pass; every text token is scored against
6
+ every rule via projected dot-product; sigmoid per (token, rule). Neutral is the
7
+ default (no rule above threshold), and new rule types need no retraining.
8
+
9
+ score[t, r] = <P_tok(h_t), P_rule(mean(h[rule_r tokens]))> / sqrt(d) + b
10
+
11
+ Input: "Policy:\n- <rule 1>\n- <rule 2>\n\nText:\n<doc>"
12
+ Labels: (T_text_tokens x R) binary matrix from span<->rule-idx supervision.
13
+ Eval: span-level F1 (span + correct rule) at sigmoid > 0.5.
14
+ """
15
+ import argparse
16
+ import json
17
+ import os
18
+
19
+ import numpy as np
20
+ import torch
21
+ import torch.nn as nn
22
+ from transformers import AutoTokenizer, Trainer, TrainingArguments
23
+ from transformers.models.lfm2.configuration_lfm2 import Lfm2Config
24
+ from transformers.models.lfm2.modeling_lfm2 import Lfm2PreTrainedModel
25
+
26
+ import modeling_lfm2_bidir_theirs as bidir
27
+
28
+ PROJ_D = 256
29
+
30
+
31
+ class Lfm2BidirForRuleMatching(Lfm2PreTrainedModel):
32
+ config_class = Lfm2Config
33
+ base_model_prefix = "lfm2"
34
+
35
+ def __init__(self, config):
36
+ super().__init__(config)
37
+ self.lfm2 = bidir.Lfm2BidirectionalModel_theirs(config)
38
+ d = getattr(config, "rule_proj_dim", PROJ_D)
39
+ self.tok_proj = nn.Linear(config.hidden_size, d)
40
+ self.rule_proj = nn.Linear(config.hidden_size, d)
41
+ self.score_bias = nn.Parameter(torch.tensor(-2.0)) # start conservative
42
+ self.post_init()
43
+
44
+ def forward(self, input_ids=None, attention_mask=None, rule_pool=None,
45
+ labels=None, label_mask=None, **kw):
46
+ # rule_pool: (B, R, T) normalized pooling weights over rule token ranges
47
+ h = self.lfm2(input_ids=input_ids, attention_mask=attention_mask,
48
+ use_cache=False, return_dict=True).last_hidden_state # (B,T,H)
49
+ rule_rep = torch.bmm(rule_pool, h) # (B,R,H)
50
+ tp = self.tok_proj(h) # (B,T,d)
51
+ rp = self.rule_proj(rule_rep) # (B,R,d)
52
+ scores = torch.einsum("btd,brd->btr", tp, rp) / (tp.shape[-1] ** 0.5) + self.score_bias
53
+ loss = None
54
+ if labels is not None:
55
+ m = label_mask.bool()
56
+ if m.any():
57
+ pos_w = torch.tensor(8.0, device=scores.device)
58
+ loss = nn.functional.binary_cross_entropy_with_logits(
59
+ scores[m], labels[m], pos_weight=pos_w)
60
+ else:
61
+ loss = scores.sum() * 0.0
62
+ return {"loss": loss, "logits": scores}
63
+
64
+
65
+ def build_prompt(policies):
66
+ return "Policy:\n" + "\n".join(f"- {p}" for p in policies) + "\n\nText:\n"
67
+
68
+
69
+ def encode_row(row, tok, max_len):
70
+ pols = row["policies"] if row["policies"] else ["(none)"]
71
+ prefix = build_prompt(pols)
72
+ full = prefix + row["text"]
73
+ enc = tok(full, truncation=True, max_length=max_len, return_offsets_mapping=True)
74
+ off = enc["offset_mapping"]
75
+ t0 = len(prefix)
76
+
77
+ # char ranges of each rule inside the prefix
78
+ ranges = []
79
+ pos = len("Policy:\n")
80
+ for ptxt in pols:
81
+ start = pos + 2 # after "- "
82
+ ranges.append((start, start + len(ptxt)))
83
+ pos = start + len(ptxt) + 1 # + newline
84
+
85
+ T = len(off)
86
+ R = len(pols)
87
+ # rule token-pooling sets
88
+ pool = np.zeros((R, T), dtype=np.float32)
89
+ for ri, (rs, re_) in enumerate(ranges):
90
+ idxs = [i for i, (a, b) in enumerate(off) if a < re_ and b > rs and a != b]
91
+ for i in idxs:
92
+ pool[ri, i] = 1.0 / max(len(idxs), 1)
93
+
94
+ # labels over text tokens
95
+ spans = [(s + t0, e + t0, ri) for s, e, ri in row["spans"]]
96
+ labels = np.zeros((T, R), dtype=np.float32)
97
+ lmask = np.zeros((T, R), dtype=np.float32)
98
+ for i, (a, b) in enumerate(off):
99
+ if b <= t0 or a == b:
100
+ continue
101
+ lmask[i, :] = 1.0
102
+ for (s, e, ri) in spans:
103
+ if a < e and b > s and 0 <= ri < R:
104
+ labels[i, ri] = 1.0
105
+ return {"input_ids": enc["input_ids"], "attention_mask": enc["attention_mask"],
106
+ "pool": pool, "labels": labels, "label_mask": lmask}
107
+
108
+
109
+ def decode_rule_spans(score_row, lmask_row, thr=0.5):
110
+ """(T,R) sigmoid scores -> set of (start,end,rule) spans over masked tokens."""
111
+ T, R = score_row.shape
112
+ spans = set()
113
+ for r in range(R):
114
+ cur = None
115
+ for i in range(T + 1):
116
+ on = i < T and lmask_row[i, r] > 0 and score_row[i, r] > thr
117
+ if on:
118
+ cur = (cur[0], i + 1, r) if cur else (i, i + 1, r)
119
+ else:
120
+ if cur: spans.add(cur)
121
+ cur = None
122
+ return spans
123
+
124
+
125
+ def gold_rule_spans(labels_row, lmask_row):
126
+ return decode_rule_spans(labels_row, lmask_row, thr=0.5)
127
+
128
+
129
+ def compute_metrics(eval_pred):
130
+ (scores, labels, lmask) = eval_pred.predictions if isinstance(eval_pred.predictions, tuple) else (eval_pred.predictions, None, None)
131
+ # Trainer passes logits only; labels via label_ids is our labels tensor
132
+ scores = 1 / (1 + np.exp(-scores))
133
+ labels, lmask = eval_pred.label_ids
134
+ tp = fp = fn = 0
135
+ for s_row, l_row, m_row in zip(scores, labels, lmask):
136
+ ps = decode_rule_spans(s_row, m_row)
137
+ gs = gold_rule_spans(l_row, m_row)
138
+ tp += len(ps & gs); fp += len(ps - gs); fn += len(gs - ps)
139
+ p = tp / max(tp + fp, 1); r = tp / max(tp + fn, 1)
140
+ return {"span_precision": p, "span_recall": r,
141
+ "span_f1": 2 * p * r / max(p + r, 1e-9)}
142
+
143
+
144
+ def main():
145
+ ap = argparse.ArgumentParser()
146
+ ap.add_argument("--data", required=True)
147
+ ap.add_argument("--base", default="LiquidAI/LFM2.5-Encoder-350M")
148
+ ap.add_argument("--out", default="bizlint_v02_ckpt")
149
+ ap.add_argument("--max-len", type=int, default=320)
150
+ ap.add_argument("--epochs", type=float, default=4)
151
+ ap.add_argument("--bsz", type=int, default=48)
152
+ ap.add_argument("--lr", type=float, default=3e-5)
153
+ ap.add_argument("--max-steps", type=int, default=-1)
154
+ args = ap.parse_args()
155
+
156
+ tok = AutoTokenizer.from_pretrained(args.base, trust_remote_code=True)
157
+ rows = [json.loads(l) for l in open(args.data)]
158
+ ds = {"train": [], "val": []}
159
+ for r in rows:
160
+ if r["split"] in ds:
161
+ ds[r["split"]].append(encode_row(r, tok, args.max_len))
162
+ print(f"train {len(ds['train'])} val {len(ds['val'])}")
163
+
164
+ cfg = Lfm2Config.from_pretrained(args.base)
165
+ cfg.rule_proj_dim = PROJ_D
166
+ model, info = Lfm2BidirForRuleMatching.from_pretrained(
167
+ args.base, config=cfg, torch_dtype=torch.float32, output_loading_info=True)
168
+ missing = [k for k in info["missing_keys"]
169
+ if not (k.startswith("tok_proj") or k.startswith("rule_proj") or k.startswith("score_bias"))]
170
+ assert not missing, f"body weights missing: {missing[:5]}"
171
+ print("load gate OK — fresh:", sorted(info["missing_keys"]))
172
+
173
+ class Collator:
174
+ def __call__(self, feats):
175
+ B = len(feats)
176
+ T = max(len(f["input_ids"]) for f in feats)
177
+ R = max(f["pool"].shape[0] for f in feats)
178
+ pad = tok.pad_token_id or 0
179
+ ii = np.full((B, T), pad, dtype=np.int64)
180
+ am = np.zeros((B, T), dtype=np.int64)
181
+ pool = np.zeros((B, R, T), dtype=np.float32)
182
+ lab = np.zeros((B, T, R), dtype=np.float32)
183
+ lm = np.zeros((B, T, R), dtype=np.float32)
184
+ for i, f in enumerate(feats):
185
+ n = len(f["input_ids"]); r = f["pool"].shape[0]
186
+ ii[i, :n] = f["input_ids"]; am[i, :n] = f["attention_mask"]
187
+ pool[i, :r, :n] = f["pool"]
188
+ lab[i, :n, :r] = f["labels"]; lm[i, :n, :r] = f["label_mask"]
189
+ return {"input_ids": torch.from_numpy(ii), "attention_mask": torch.from_numpy(am),
190
+ "rule_pool": torch.from_numpy(pool),
191
+ "labels": torch.from_numpy(lab), "label_mask": torch.from_numpy(lm)}
192
+
193
+ class RuleTrainer(Trainer):
194
+ def prediction_step(self, model, inputs, prediction_loss_only, ignore_keys=None):
195
+ with torch.no_grad():
196
+ out = model(**{k: v.to(model.device) for k, v in inputs.items()})
197
+ return (out["loss"].detach() if out["loss"] is not None else None,
198
+ out["logits"].detach().float().cpu(),
199
+ (inputs["labels"].cpu(), inputs["label_mask"].cpu()))
200
+
201
+ targs = TrainingArguments(
202
+ output_dir=args.out,
203
+ num_train_epochs=args.epochs, max_steps=args.max_steps,
204
+ per_device_train_batch_size=args.bsz, per_device_eval_batch_size=args.bsz,
205
+ learning_rate=args.lr, warmup_ratio=0.06, weight_decay=0.01,
206
+ lr_scheduler_type="cosine", bf16=True,
207
+ logging_steps=20, eval_strategy="epoch", save_strategy="no",
208
+ report_to=[], remove_unused_columns=False, seed=0,
209
+ eval_do_concat_batches=False,
210
+ )
211
+
212
+ def cm(eval_pred):
213
+ # eval_do_concat_batches=False: predictions/label_ids are LISTS of batches
214
+ tp = fp = fn = 0
215
+ for scores, (labels, lmask) in zip(eval_pred.predictions, eval_pred.label_ids):
216
+ sc = 1 / (1 + np.exp(-np.asarray(scores)))
217
+ for s_row, l_row, m_row in zip(sc, np.asarray(labels), np.asarray(lmask)):
218
+ ps = decode_rule_spans(s_row, m_row)
219
+ gs = gold_rule_spans(l_row, m_row)
220
+ tp += len(ps & gs); fp += len(ps - gs); fn += len(gs - ps)
221
+ p = tp / max(tp + fp, 1); r = tp / max(tp + fn, 1)
222
+ return {"span_precision": p, "span_recall": r, "span_f1": 2 * p * r / max(p + r, 1e-9)}
223
+
224
+ trainer = RuleTrainer(model=model, args=targs, train_dataset=ds["train"],
225
+ eval_dataset=ds["val"], data_collator=Collator(),
226
+ compute_metrics=cm)
227
+ trainer.train()
228
+ m = trainer.evaluate()
229
+ print("FINAL_VAL:", json.dumps({k: round(v, 4) for k, v in m.items() if isinstance(v, float)}))
230
+
231
+ model.save_pretrained(os.path.join(args.out, "final"))
232
+ tok.save_pretrained(os.path.join(args.out, "final"))
233
+ print("SAVED", os.path.join(args.out, "final"))
234
+
235
+
236
+ if __name__ == "__main__":
237
+ main()