Add an AutoModel custom code implementation

#1
by tomaarsen HF Staff - opened
README.md CHANGED
@@ -27,38 +27,41 @@ backbone, `use_bidirectional_attention=true`) with two heads:
27
 
28
  ## Usage
29
 
30
- [`example_usage.py`](example_usage.py) in this repo is a self-contained
31
- reference pipeline (`pip install torch safetensors transformers`). It loads
32
- the encoder implementation from the
33
- [backbone repo](https://huggingface.co/perplexity-ai/pplx-embed-v1-0.6b) via
34
- `trust_remote_code`, swaps in this repo's fine-tuned weights, applies the two
35
- heads, and decodes spans with the constrained BIOES Viterbi included in the
36
- script:
37
 
38
  ```python
39
- import sys
40
 
41
- from huggingface_hub import snapshot_download
42
-
43
- repo = snapshot_download("perplexity-ai/pplx-pii-masking")
44
- sys.path.insert(0, repo)
45
- from example_usage import PiiMasker
46
-
47
- masker = PiiMasker(repo)
48
 
49
  text = ("Hi, I'm Daniel Whitfield, you can reach me at "
50
  "daniels@meridiancap.com or 415-555-0123.")
51
- spans, sensitivity = masker(text)
52
  for s in spans:
53
  print(s.label, (s.start, s.end), text[s.start:s.end])
54
  # private_person (8, 24) Daniel Whitfield
55
  # private_email (46, 69) daniels@meridiancap.com
56
  # private_phone (73, 85) 415-555-0123
57
 
58
- print(masker.mask(text))
59
  # Hi, I'm [PRIVATE_PERSON], you can reach me at [PRIVATE_EMAIL] or [PRIVATE_PHONE].
60
  ```
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  ## Checkpoint layout
63
 
64
  `model.safetensors` holds the fine-tuned backbone (bf16, `backbone.*`), both
@@ -68,6 +71,7 @@ scalars (`viterbi.*`). `max_seq_len` is 4096 tokens.
68
  Inference outline: tokenize (no BOS/EOS added), run the bidirectional encoder,
69
  then per token `logits = h @ W_cls.T + b_cls` decoded with a constrained BIOES
70
  Viterbi, and `sensitivity = sigmoid(mean(h) @ W_sen.T + b_sen)`. The
71
- `PPLXQwen3Model` encoder implementation (`configuration.py` / `modeling.py`
72
- referenced by `config.json`'s `auto_map`) ships with the
73
- [backbone repo](https://huggingface.co/perplexity-ai/pplx-embed-v1-0.6b).
 
 
27
 
28
  ## Usage
29
 
30
+ `pip install torch transformers`, then load the model with `trust_remote_code`:
 
 
 
 
 
 
31
 
32
  ```python
33
+ from transformers import AutoModel
34
 
35
+ model = AutoModel.from_pretrained(
36
+ "perplexity-ai/pplx-pii-masking", trust_remote_code=True
37
+ )
 
 
 
 
38
 
39
  text = ("Hi, I'm Daniel Whitfield, you can reach me at "
40
  "daniels@meridiancap.com or 415-555-0123.")
41
+ spans, sensitivity = model.predict(text)
42
  for s in spans:
43
  print(s.label, (s.start, s.end), text[s.start:s.end])
44
  # private_person (8, 24) Daniel Whitfield
45
  # private_email (46, 69) daniels@meridiancap.com
46
  # private_phone (73, 85) 415-555-0123
47
 
48
+ print(model.mask(text))
49
  # Hi, I'm [PRIVATE_PERSON], you can reach me at [PRIVATE_EMAIL] or [PRIVATE_PHONE].
50
  ```
51
 
52
+ `predict` and `mask` wrap `model(input_ids, attention_mask)`, which returns the
53
+ 37 BIOES tag logits per token and one sensitivity logit per document. The
54
+ implementation is
55
+ [`modeling_pii_masking.py`](modeling_pii_masking.py) in this repo: it builds
56
+ the encoder from [`modeling_pplx_qwen3.py`](modeling_pplx_qwen3.py) (vendored
57
+ from the
58
+ [backbone repo](https://huggingface.co/perplexity-ai/pplx-embed-v1-0.6b)),
59
+ applies this repo's fine-tuned weights and the two heads, and decodes spans
60
+ with the constrained BIOES Viterbi included in the file. Input is truncated
61
+ to `max_seq_len` (4096) tokens; chunk longer documents before calling
62
+ `predict`. `model.save_pretrained(dir)` also saves the tokenizer, so the saved
63
+ directory loads and predicts on its own.
64
+
65
  ## Checkpoint layout
66
 
67
  `model.safetensors` holds the fine-tuned backbone (bf16, `backbone.*`), both
 
71
  Inference outline: tokenize (no BOS/EOS added), run the bidirectional encoder,
72
  then per token `logits = h @ W_cls.T + b_cls` decoded with a constrained BIOES
73
  Viterbi, and `sensitivity = sigmoid(mean(h) @ W_sen.T + b_sen)`. The
74
+ `PPLXQwen3Model` encoder implementation (`configuration_pplx_qwen3.py` /
75
+ `modeling_pplx_qwen3.py`) is vendored from the
76
+ [backbone repo](https://huggingface.co/perplexity-ai/pplx-embed-v1-0.6b);
77
+ `config.json`'s `backbone` holds its config.
config.json CHANGED
@@ -1,4 +1,13 @@
1
  {
 
 
 
 
 
 
 
 
 
2
  "model_type": "pii_masking",
3
  "backbone": {
4
  "vocab_size": 151936,
@@ -75,10 +84,6 @@
75
  "problem_type": null,
76
  "_name_or_path": "perplexity-ai/pplx-embed-v1-0.6b",
77
  "transformers_version": "5.2.0",
78
- "auto_map": {
79
- "AutoConfig": "configuration.PPLXQwen3Config",
80
- "AutoModel": "modeling.PPLXQwen3Model"
81
- },
82
  "model_type": "bidirectional_pplx_qwen3",
83
  "use_bidirectional_attention": true,
84
  "output_attentions": false
@@ -92,4 +97,4 @@
92
  "viterbi_b_bias": 0.0,
93
  "viterbi_e_bias": 0.0,
94
  "freeze_backbone": false
95
- }
 
1
  {
2
+ "architectures": [
3
+ "PiiMaskingModel"
4
+ ],
5
+ "auto_map": {
6
+ "AutoConfig": "modeling_pii_masking.PiiMaskingConfig",
7
+ "AutoModel": "modeling_pii_masking.PiiMaskingModel"
8
+ },
9
+ "dtype": "bfloat16",
10
+ "transformers_version": "5.2.0",
11
  "model_type": "pii_masking",
12
  "backbone": {
13
  "vocab_size": 151936,
 
84
  "problem_type": null,
85
  "_name_or_path": "perplexity-ai/pplx-embed-v1-0.6b",
86
  "transformers_version": "5.2.0",
 
 
 
 
87
  "model_type": "bidirectional_pplx_qwen3",
88
  "use_bidirectional_attention": true,
89
  "output_attentions": false
 
97
  "viterbi_b_bias": 0.0,
98
  "viterbi_e_bias": 0.0,
99
  "freeze_backbone": false
100
+ }
configuration_pplx_qwen3.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # Vendored from perplexity-ai/pplx-embed-v1-0.6b @ 2c4d510dd4a732063c31a0f70193e35067b51fd8
2
+ # (configuration.py). Kept in this repo so loading needs no code from another repo.
3
+ from transformers.models.qwen3.configuration_qwen3 import Qwen3Config
4
+
5
+
6
+ class PPLXQwen3Config(Qwen3Config):
7
+ model_type = "bidirectional_pplx_qwen3"
example_usage.py → modeling_pii_masking.py RENAMED
@@ -1,25 +1,35 @@
1
- #!/usr/bin/env python3
2
  """Detect and mask PII with pplx-pii-masking.
3
 
4
- Self-contained: the encoder implementation (`PPLXQwen3Model`) is pulled from
5
- the public backbone repo perplexity-ai/pplx-embed-v1-0.6b via
6
- `trust_remote_code`, the fine-tuned weights come from this repo, and the
7
- constrained BIOES Viterbi decoder is inlined below.
8
 
9
- pip install torch safetensors transformers
10
- python example_usage.py "some text with daniels@meridiancap.com in it"
 
 
 
 
 
 
 
 
 
 
11
  """
12
  from __future__ import annotations
13
 
14
- import sys
15
  from dataclasses import dataclass
16
- from pathlib import Path
17
 
18
  import torch
19
- from safetensors.torch import load_file
20
- from transformers import AutoModel, AutoTokenizer
 
 
 
 
21
 
22
- BACKBONE_REPO = "perplexity-ai/pplx-embed-v1-0.6b"
 
 
23
 
24
  PII_TYPES = [
25
  "private_person",
@@ -66,24 +76,27 @@ def strip_span_whitespace(text: str, spans: list[PredictedSpan]) -> list[Predict
66
  return out
67
 
68
 
69
- class ViterbiDecoder:
70
  """Constrained BIOES Viterbi decoder with transition bias scalars.
71
 
72
  The bias scalars are added to all ->B and E-> transitions, allowing
73
- precision/recall trade-offs without retraining.
 
74
  """
75
 
76
  def __init__(self, labels: list[str], b_bias: float = 0.0, e_bias: float = 0.0):
 
77
  self.labels = labels
78
  self.num_labels = len(labels)
79
  self.label2id = {label: idx for idx, label in enumerate(labels)}
80
  self.id2label = {idx: label for idx, label in enumerate(labels)}
81
  self.pii_types = [label[2:] for label in labels if label.startswith("S-")]
82
- self.b_bias = b_bias
83
- self.e_bias = e_bias
 
84
 
85
- def _build_transition_scores(self) -> torch.Tensor:
86
- """[num_labels, num_labels] float transition score matrix."""
87
  n, l2i = self.num_labels, self.label2id
88
  mask = torch.zeros(n, n, dtype=torch.bool)
89
  end_states = {l2i["O"]} # states a span can end on (O, E-*, S-*)
@@ -98,17 +111,22 @@ class ViterbiDecoder:
98
  for from_state in end_states:
99
  for to_state in begin_states:
100
  mask[from_state, to_state] = True
 
101
 
 
 
 
 
102
  scores = torch.full((n, n), float("-inf"))
103
- scores[mask] = 0.0
104
  for pii_type in self.pii_types:
105
  b, e = l2i[f"B-{pii_type}"], l2i[f"E-{pii_type}"]
106
  for from_s in range(n):
107
  if scores[from_s, b] > float("-inf"):
108
- scores[from_s, b] += self.b_bias # entering B states
109
  for to_s in range(n):
110
  if scores[e, to_s] > float("-inf"):
111
- scores[e, to_s] += self.e_bias # leaving E states
112
  return scores
113
 
114
  @torch.no_grad()
@@ -214,62 +232,156 @@ class ViterbiDecoder:
214
 
215
 
216
  # ---------------------------------------------------------------------------
217
- # Model loading and inference
218
  # ---------------------------------------------------------------------------
219
 
220
- class PiiMasker:
221
- def __init__(self, repo_dir: str | Path, device: str = "cpu"):
222
- repo_dir = Path(repo_dir)
223
- self.device = device
224
- self.tokenizer = AutoTokenizer.from_pretrained(repo_dir)
225
-
226
- # Encoder architecture from the public backbone repo; weights are then
227
- # replaced with the fine-tuned backbone from this checkpoint.
228
- self.backbone = AutoModel.from_pretrained(
229
- BACKBONE_REPO, dtype=torch.bfloat16, trust_remote_code=True
230
- ).to(device).eval()
231
-
232
- sd = load_file(str(repo_dir / "model.safetensors"))
233
- self.backbone.load_state_dict(
234
- {k.removeprefix("backbone."): v for k, v in sd.items()
235
- if k.startswith("backbone.")}
 
 
 
 
 
 
 
 
 
 
 
 
236
  )
237
- self.w_cls = sd["token_cls_head.weight"].float().to(device) # [37, 1024]
238
- self.b_cls = sd["token_cls_head.bias"].float().to(device)
239
- self.w_sen = sd["sensitivity_head.weight"].float().to(device) # [1, 1024]
240
- self.b_sen = sd["sensitivity_head.bias"].float().to(device)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  self.viterbi = ViterbiDecoder(
242
- BIOES_LABELS,
243
- b_bias=float(sd["viterbi.b_bias"].item()),
244
- e_bias=float(sd["viterbi.e_bias"].item()),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  )
246
 
247
  @torch.no_grad()
248
- def __call__(self, text: str) -> tuple[list[PredictedSpan], float]:
249
- enc = self.tokenizer(text, return_offsets_mapping=True,
250
- return_tensors="pt", truncation=True, max_length=4096)
251
- h = self.backbone(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  input_ids=enc["input_ids"].to(self.device),
253
  attention_mask=enc["attention_mask"].to(self.device),
254
- ).last_hidden_state[0].float() # [T, 1024]
255
- logits = h @ self.w_cls.T + self.b_cls # [T, 37]
256
- sensitivity = torch.sigmoid(h.mean(0) @ self.w_sen.T + self.b_sen).item()
257
  offsets = [tuple(o) for o in enc["offset_mapping"][0].tolist()]
258
- spans = self.viterbi.decode(logits.cpu(), offsets, text=text)
259
- return spans, sensitivity
260
 
261
- def mask(self, text: str) -> str:
262
- spans, _ = self(text)
 
263
  for s in sorted(spans, key=lambda s: -s.start):
264
- text = text[:s.start] + f"[{s.label.upper()}]" + text[s.end:]
 
265
  return text
266
-
267
-
268
- if __name__ == "__main__":
269
- text = sys.argv[1]
270
- masker = PiiMasker(Path(__file__).parent)
271
- spans, sensitivity = masker(text)
272
- print(f"sensitivity: {sensitivity:.3f}")
273
- for s in spans:
274
- print(f" {s.label:18s} [{s.start}:{s.end}] {text[s.start:s.end]!r}")
275
- print(masker.mask(text))
 
 
1
  """Detect and mask PII with pplx-pii-masking.
2
 
3
+ Loaded through `AutoModel`, which pulls this file in as remote code:
 
 
 
4
 
5
+ from transformers import AutoModel
6
+
7
+ model = AutoModel.from_pretrained(
8
+ "perplexity-ai/pplx-pii-masking", trust_remote_code=True
9
+ )
10
+ spans, sensitivity = model.predict("Hi, I'm Daniel Whitfield")
11
+ print(model.mask("Hi, I'm Daniel Whitfield"))
12
+
13
+ The encoder implementation (`PPLXQwen3Model`) is vendored from the public
14
+ backbone repo perplexity-ai/pplx-embed-v1-0.6b (`modeling_pplx_qwen3.py`), the
15
+ fine-tuned weights come from this repo, and the constrained BIOES Viterbi
16
+ decoder is inlined below.
17
  """
18
  from __future__ import annotations
19
 
 
20
  from dataclasses import dataclass
 
21
 
22
  import torch
23
+ from torch import nn
24
+ from transformers import AutoTokenizer, PretrainedConfig, PreTrainedModel
25
+ from transformers.utils import ModelOutput
26
+
27
+ from .configuration_pplx_qwen3 import PPLXQwen3Config
28
+ from .modeling_pplx_qwen3 import PPLXQwen3Model
29
 
30
+ # from_pretrained kwargs that also apply when loading the tokenizer next to the weights
31
+ HUB_KWARGS = ("cache_dir", "force_download", "local_files_only", "proxies",
32
+ "revision", "subfolder", "token")
33
 
34
  PII_TYPES = [
35
  "private_person",
 
76
  return out
77
 
78
 
79
+ class ViterbiDecoder(nn.Module):
80
  """Constrained BIOES Viterbi decoder with transition bias scalars.
81
 
82
  The bias scalars are added to all ->B and E-> transitions, allowing
83
+ precision/recall trade-offs without retraining. They and the legal
84
+ transition mask are buffers, so they come from the checkpoint.
85
  """
86
 
87
  def __init__(self, labels: list[str], b_bias: float = 0.0, e_bias: float = 0.0):
88
+ super().__init__()
89
  self.labels = labels
90
  self.num_labels = len(labels)
91
  self.label2id = {label: idx for idx, label in enumerate(labels)}
92
  self.id2label = {idx: label for idx, label in enumerate(labels)}
93
  self.pii_types = [label[2:] for label in labels if label.startswith("S-")]
94
+ self.register_buffer("b_bias", torch.tensor([float(b_bias)]))
95
+ self.register_buffer("e_bias", torch.tensor([float(e_bias)]))
96
+ self.register_buffer("transition_mask", self._build_transition_mask())
97
 
98
+ def _build_transition_mask(self) -> torch.Tensor:
99
+ """[num_labels, num_labels] bool mask of the legal tag transitions."""
100
  n, l2i = self.num_labels, self.label2id
101
  mask = torch.zeros(n, n, dtype=torch.bool)
102
  end_states = {l2i["O"]} # states a span can end on (O, E-*, S-*)
 
111
  for from_state in end_states:
112
  for to_state in begin_states:
113
  mask[from_state, to_state] = True
114
+ return mask
115
 
116
+ def _build_transition_scores(self) -> torch.Tensor:
117
+ """[num_labels, num_labels] float transition score matrix."""
118
+ n, l2i = self.num_labels, self.label2id
119
+ b_bias, e_bias = float(self.b_bias), float(self.e_bias)
120
  scores = torch.full((n, n), float("-inf"))
121
+ scores[self.transition_mask.bool().cpu()] = 0.0 # decoding runs on cpu
122
  for pii_type in self.pii_types:
123
  b, e = l2i[f"B-{pii_type}"], l2i[f"E-{pii_type}"]
124
  for from_s in range(n):
125
  if scores[from_s, b] > float("-inf"):
126
+ scores[from_s, b] += b_bias # entering B states
127
  for to_s in range(n):
128
  if scores[e, to_s] > float("-inf"):
129
+ scores[e, to_s] += e_bias # leaving E states
130
  return scores
131
 
132
  @torch.no_grad()
 
232
 
233
 
234
  # ---------------------------------------------------------------------------
235
+ # Model
236
  # ---------------------------------------------------------------------------
237
 
238
+ class PiiMaskingConfig(PretrainedConfig):
239
+ """Config for `PiiMaskingModel`.
240
+
241
+ `backbone` stays a plain dict; `PiiMaskingModel` turns it into a
242
+ `PPLXQwen3Config`.
243
+ """
244
+
245
+ model_type = "pii_masking"
246
+
247
+ def __init__(
248
+ self,
249
+ backbone: dict | None = None,
250
+ hidden_size: int = 1024,
251
+ num_token_labels: int = len(BIOES_LABELS),
252
+ max_seq_len: int = 4096,
253
+ viterbi_b_bias: float = 0.0,
254
+ viterbi_e_bias: float = 0.0,
255
+ **kwargs,
256
+ ):
257
+ self.backbone = backbone or {}
258
+ self.hidden_size = hidden_size
259
+ self.num_token_labels = num_token_labels
260
+ self.max_seq_len = max_seq_len
261
+ self.viterbi_b_bias = viterbi_b_bias
262
+ self.viterbi_e_bias = viterbi_e_bias
263
+ kwargs.setdefault("id2label", dict(enumerate(BIOES_LABELS)))
264
+ kwargs.setdefault(
265
+ "label2id", {label: i for i, label in enumerate(BIOES_LABELS)}
266
  )
267
+ super().__init__(**kwargs)
268
+
269
+
270
+ @dataclass
271
+ class PiiMaskingOutput(ModelOutput):
272
+ """[B, T, 37] tag logits, [B] sensitivity logits, [B, T, H] encoder output."""
273
+
274
+ logits: torch.FloatTensor | None = None
275
+ sensitivity_logits: torch.FloatTensor | None = None
276
+ last_hidden_state: torch.FloatTensor | None = None
277
+
278
+
279
+ class PiiMaskingModel(PreTrainedModel):
280
+ """Bidirectional Qwen3 encoder with PII token and sensitivity heads."""
281
+
282
+ config_class = PiiMaskingConfig
283
+ _supports_sdpa = True
284
+ _supports_flash_attn = True
285
+ # The heads and the decoder biases are fp32 in the checkpoint while the
286
+ # encoder is bf16. Keep them fp32 whichever dtype the model is loaded in.
287
+ _keep_in_fp32_modules_strict = [
288
+ "token_cls_head", "sensitivity_head", "viterbi.b_bias", "viterbi.e_bias",
289
+ ]
290
+
291
+ def __init__(self, config: PiiMaskingConfig):
292
+ super().__init__(config)
293
+ backbone_config = PPLXQwen3Config.from_dict(config.backbone)
294
+ backbone_config._attn_implementation = config._attn_implementation
295
+ self.backbone = PPLXQwen3Model(backbone_config)
296
+ self.token_cls_head = nn.Linear(config.hidden_size, config.num_token_labels)
297
+ self.sensitivity_head = nn.Linear(config.hidden_size, 1)
298
  self.viterbi = ViterbiDecoder(
299
+ BIOES_LABELS, config.viterbi_b_bias, config.viterbi_e_bias
300
+ )
301
+ self._tokenizer = None
302
+ self._tokenizer_kwargs: dict = {}
303
+ self.post_init()
304
+
305
+ @classmethod
306
+ def from_pretrained(cls, pretrained_model_name_or_path, *model_args, **kwargs):
307
+ model = super().from_pretrained(pretrained_model_name_or_path, *model_args, **kwargs)
308
+ # Same revision / token / cache as the weights, so `predict` on a model
309
+ # loaded from a branch or a private repo finds the matching tokenizer.
310
+ model._tokenizer_kwargs = {k: kwargs[k] for k in HUB_KWARGS if k in kwargs}
311
+ return model
312
+
313
+ def save_pretrained(self, save_directory, *args, **kwargs):
314
+ super().save_pretrained(save_directory, *args, **kwargs)
315
+ # The saved directory must work with `predict`, which needs the tokenizer.
316
+ if self._tokenizer is not None or self.config._name_or_path:
317
+ self.tokenizer.save_pretrained(save_directory)
318
+
319
+ @property
320
+ def tokenizer(self):
321
+ """Tokenizer used by `predict`, loaded from the checkpoint on first use."""
322
+ if self._tokenizer is None:
323
+ self._tokenizer = AutoTokenizer.from_pretrained(
324
+ self.config._name_or_path, **self._tokenizer_kwargs
325
+ )
326
+ return self._tokenizer
327
+
328
+ @tokenizer.setter
329
+ def tokenizer(self, value):
330
+ self._tokenizer = value
331
+
332
+ def forward(
333
+ self,
334
+ input_ids: torch.LongTensor | None = None,
335
+ attention_mask: torch.Tensor | None = None,
336
+ **kwargs,
337
+ ) -> PiiMaskingOutput:
338
+ last_hidden_state = self.backbone(
339
+ input_ids=input_ids, attention_mask=attention_mask, **kwargs
340
+ ).last_hidden_state # [B, T, 1024]
341
+ h = last_hidden_state.to(self.token_cls_head.weight.dtype) # heads are fp32
342
+
343
+ if attention_mask is None:
344
+ pooled = h.mean(dim=1)
345
+ else:
346
+ mask = attention_mask.to(h.dtype).unsqueeze(-1)
347
+ pooled = (h * mask).sum(dim=1) / mask.sum(dim=1).clamp(min=1.0)
348
+
349
+ return PiiMaskingOutput(
350
+ logits=self.token_cls_head(h),
351
+ sensitivity_logits=self.sensitivity_head(pooled).squeeze(-1),
352
+ last_hidden_state=last_hidden_state,
353
  )
354
 
355
  @torch.no_grad()
356
+ def predict(
357
+ self,
358
+ text: str,
359
+ tokenizer=None,
360
+ max_length: int | None = None,
361
+ ) -> tuple[list[PredictedSpan], float]:
362
+ """Predicted PII spans, and the document sensitivity in [0, 1].
363
+
364
+ Text past `max_length` tokens is truncated, so spans there are not
365
+ reported. An empty document has no spans and sensitivity 0.0.
366
+ """
367
+ tokenizer = tokenizer if tokenizer is not None else self.tokenizer
368
+ enc = tokenizer(text, return_offsets_mapping=True, return_tensors="pt",
369
+ truncation=True,
370
+ max_length=max_length or self.config.max_seq_len)
371
+ if enc["input_ids"].shape[1] == 0: # empty document, nothing to encode
372
+ return [], 0.0
373
+ out = self(
374
  input_ids=enc["input_ids"].to(self.device),
375
  attention_mask=enc["attention_mask"].to(self.device),
376
+ )
 
 
377
  offsets = [tuple(o) for o in enc["offset_mapping"][0].tolist()]
378
+ spans = self.viterbi.decode(out.logits[0].float().cpu(), offsets, text=text)
379
+ return spans, float(out.sensitivity_logits.float().sigmoid())
380
 
381
+ def mask(self, text: str, placeholder: str = "[{label}]", **kwargs) -> str:
382
+ """The text with every predicted span replaced by e.g. [PRIVATE_EMAIL]."""
383
+ spans, _ = self.predict(text, **kwargs)
384
  for s in sorted(spans, key=lambda s: -s.start):
385
+ filled = placeholder.format(label=s.label.upper())
386
+ text = text[:s.start] + filled + text[s.end:]
387
  return text
 
 
 
 
 
 
 
 
 
 
modeling_pplx_qwen3.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Vendored from perplexity-ai/pplx-embed-v1-0.6b @ 2c4d510dd4a732063c31a0f70193e35067b51fd8
2
+ # (modeling.py). Kept in this repo so loading needs no code from another repo.
3
+ import inspect
4
+ from typing import Callable
5
+ import torch
6
+ from transformers import Qwen3Model
7
+ from transformers.cache_utils import Cache
8
+ from transformers.masking_utils import create_causal_mask
9
+ from transformers.modeling_outputs import BaseModelOutputWithPooling
10
+ from transformers.processing_utils import Unpack
11
+ from transformers.utils import TransformersKwargs
12
+ from .configuration_pplx_qwen3 import PPLXQwen3Config
13
+
14
+ # The transformers `create_causal_mask` signature has shifted over releases:
15
+ # * <= 5.1: kwarg is `input_embeds`, `cache_position` is required positional
16
+ # * 5.2 - 5.5: renamed to `inputs_embeds`, `cache_position` still required
17
+ # * 5.6 - 5.8: `cache_position` has a default (kept for BC)
18
+ # * >= 5.9: `cache_position` removed entirely
19
+ # Detect once at import time which names this transformers exposes.
20
+ _CCM_PARAMS = inspect.signature(create_causal_mask).parameters
21
+ _CCM_EMBEDS_KEY = "inputs_embeds" if "inputs_embeds" in _CCM_PARAMS else "input_embeds"
22
+ _CCM_ACCEPTS_CACHE_POSITION = "cache_position" in _CCM_PARAMS
23
+
24
+
25
+ # From modeling_t5gemma.py
26
+ def bidirectional_mask_function(attention_mask: torch.Tensor | None) -> Callable:
27
+ """
28
+ This creates bidirectional attention mask.
29
+ """
30
+
31
+ def inner_mask(batch_idx: int, head_idx: int, q_idx: int, kv_idx: int) -> bool:
32
+ if attention_mask is None:
33
+ return torch.ones((), dtype=torch.bool)
34
+ return attention_mask[batch_idx, kv_idx].to(torch.bool)
35
+
36
+ return inner_mask
37
+
38
+
39
+ class PPLXQwen3Model(Qwen3Model):
40
+ _supports_flash_attn = True
41
+ _supports_sdpa = True
42
+
43
+ config_class = PPLXQwen3Config
44
+
45
+ def __init__(self, config):
46
+ super().__init__(config)
47
+ self.post_init()
48
+
49
+ def post_init(self):
50
+ super().post_init()
51
+ # Override to set all layers to non-causal attention. This'll work with attn_implementation="flash_attention_2" or "sdpa"
52
+ for layer in self.layers:
53
+ layer.self_attn.is_causal = False
54
+
55
+ def forward(
56
+ self,
57
+ input_ids: torch.LongTensor | None = None,
58
+ attention_mask: torch.Tensor | None = None,
59
+ position_ids: torch.LongTensor | None = None,
60
+ past_key_values: Cache | None = None,
61
+ inputs_embeds: torch.FloatTensor | None = None,
62
+ use_cache: bool | None = None,
63
+ cache_position: torch.LongTensor | None = None,
64
+ **kwargs: Unpack[TransformersKwargs],
65
+ ) -> BaseModelOutputWithPooling:
66
+ if inputs_embeds is None:
67
+ inputs_embeds = self.embed_tokens(input_ids)
68
+ input_ids = None
69
+
70
+ mask_kwargs = {
71
+ "config": self.config,
72
+ _CCM_EMBEDS_KEY: inputs_embeds,
73
+ "attention_mask": attention_mask,
74
+ "past_key_values": None,
75
+ "position_ids": position_ids,
76
+ "or_mask_function": bidirectional_mask_function(attention_mask),
77
+ }
78
+ if _CCM_ACCEPTS_CACHE_POSITION:
79
+ mask_kwargs["cache_position"] = torch.arange(
80
+ inputs_embeds.shape[1], device=inputs_embeds.device, dtype=torch.long
81
+ )
82
+ attention_mask = {"full_attention": create_causal_mask(**mask_kwargs)}
83
+
84
+ outputs = super().forward(
85
+ input_ids=input_ids,
86
+ attention_mask=attention_mask,
87
+ position_ids=position_ids,
88
+ past_key_values=past_key_values,
89
+ inputs_embeds=inputs_embeds,
90
+ use_cache=use_cache,
91
+ cache_position=cache_position,
92
+ **kwargs,
93
+ )
94
+ return outputs