tomaarsen HF Staff commited on
Commit
63f3fd4
·
verified ·
1 Parent(s): 1e6bb1e

Add an AutoModel custom code implementation

Browse files
README.md CHANGED
@@ -27,38 +27,37 @@ 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
@@ -69,5 +68,5 @@ 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 loads the
56
+ encoder implementation from the
57
+ [backbone repo](https://huggingface.co/perplexity-ai/pplx-embed-v1-0.6b),
58
+ applies this repo's fine-tuned weights and the two heads, and decodes spans
59
+ with the constrained BIOES Viterbi included in the file.
60
+
61
  ## Checkpoint layout
62
 
63
  `model.safetensors` holds the fine-tuned backbone (bf16, `backbone.*`), both
 
68
  then per token `logits = h @ W_cls.T + b_cls` decoded with a constrained BIOES
69
  Viterbi, and `sensitivity = sigmoid(mean(h) @ W_sen.T + b_sen)`. The
70
  `PPLXQwen3Model` encoder implementation (`configuration.py` / `modeling.py`
71
+ referenced by `config.json`'s `backbone.auto_map`) ships with the
72
  [backbone repo](https://huggingface.co/perplexity-ai/pplx-embed-v1-0.6b).
config.json CHANGED
@@ -1,4 +1,12 @@
1
  {
 
 
 
 
 
 
 
 
2
  "model_type": "pii_masking",
3
  "backbone": {
4
  "vocab_size": 151936,
 
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
  "model_type": "pii_masking",
11
  "backbone": {
12
  "vocab_size": 151936,
example_usage.py → modeling_pii_masking.py RENAMED
@@ -1,23 +1,28 @@
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
 
@@ -66,24 +71,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 +106,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 +227,140 @@ 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 pulled from the public backbone
14
+ repo perplexity-ai/pplx-embed-v1-0.6b, the fine-tuned weights come from this
15
+ repo, and the constrained BIOES Viterbi decoder is inlined below.
16
  """
17
  from __future__ import annotations
18
 
 
19
  from dataclasses import dataclass
 
20
 
21
  import torch
22
+ from torch import nn
23
+ from transformers import AutoTokenizer, PretrainedConfig, PreTrainedModel
24
+ from transformers.dynamic_module_utils import get_class_from_dynamic_module
25
+ from transformers.utils import ModelOutput
26
 
27
  BACKBONE_REPO = "perplexity-ai/pplx-embed-v1-0.6b"
28
 
 
71
  return out
72
 
73
 
74
+ class ViterbiDecoder(nn.Module):
75
  """Constrained BIOES Viterbi decoder with transition bias scalars.
76
 
77
  The bias scalars are added to all ->B and E-> transitions, allowing
78
+ precision/recall trade-offs without retraining. They and the legal
79
+ transition mask are buffers, so they come from the checkpoint.
80
  """
81
 
82
  def __init__(self, labels: list[str], b_bias: float = 0.0, e_bias: float = 0.0):
83
+ super().__init__()
84
  self.labels = labels
85
  self.num_labels = len(labels)
86
  self.label2id = {label: idx for idx, label in enumerate(labels)}
87
  self.id2label = {idx: label for idx, label in enumerate(labels)}
88
  self.pii_types = [label[2:] for label in labels if label.startswith("S-")]
89
+ self.register_buffer("b_bias", torch.tensor([float(b_bias)]))
90
+ self.register_buffer("e_bias", torch.tensor([float(e_bias)]))
91
+ self.register_buffer("transition_mask", self._build_transition_mask())
92
 
93
+ def _build_transition_mask(self) -> torch.Tensor:
94
+ """[num_labels, num_labels] bool mask of the legal tag transitions."""
95
  n, l2i = self.num_labels, self.label2id
96
  mask = torch.zeros(n, n, dtype=torch.bool)
97
  end_states = {l2i["O"]} # states a span can end on (O, E-*, S-*)
 
106
  for from_state in end_states:
107
  for to_state in begin_states:
108
  mask[from_state, to_state] = True
109
+ return mask
110
 
111
+ def _build_transition_scores(self) -> torch.Tensor:
112
+ """[num_labels, num_labels] float transition score matrix."""
113
+ n, l2i = self.num_labels, self.label2id
114
+ b_bias, e_bias = float(self.b_bias), float(self.e_bias)
115
  scores = torch.full((n, n), float("-inf"))
116
+ scores[self.transition_mask.bool().cpu()] = 0.0 # decoding runs on cpu
117
  for pii_type in self.pii_types:
118
  b, e = l2i[f"B-{pii_type}"], l2i[f"E-{pii_type}"]
119
  for from_s in range(n):
120
  if scores[from_s, b] > float("-inf"):
121
+ scores[from_s, b] += b_bias # entering B states
122
  for to_s in range(n):
123
  if scores[e, to_s] > float("-inf"):
124
+ scores[e, to_s] += e_bias # leaving E states
125
  return scores
126
 
127
  @torch.no_grad()
 
227
 
228
 
229
  # ---------------------------------------------------------------------------
230
+ # Model
231
  # ---------------------------------------------------------------------------
232
 
233
+ class PiiMaskingConfig(PretrainedConfig):
234
+ """Config for `PiiMaskingModel`.
235
+
236
+ `backbone` stays a plain dict because the encoder config class lives in the
237
+ backbone repo rather than in transformers.
238
+ """
239
+
240
+ model_type = "pii_masking"
241
+
242
+ def __init__(
243
+ self,
244
+ backbone: dict | None = None,
245
+ hidden_size: int = 1024,
246
+ num_token_labels: int = len(BIOES_LABELS),
247
+ max_seq_len: int = 4096,
248
+ viterbi_b_bias: float = 0.0,
249
+ viterbi_e_bias: float = 0.0,
250
+ **kwargs,
251
+ ):
252
+ self.backbone = backbone or {}
253
+ self.hidden_size = hidden_size
254
+ self.num_token_labels = num_token_labels
255
+ self.max_seq_len = max_seq_len
256
+ self.viterbi_b_bias = viterbi_b_bias
257
+ self.viterbi_e_bias = viterbi_e_bias
258
+ kwargs.setdefault("id2label", dict(enumerate(BIOES_LABELS)))
259
+ kwargs.setdefault(
260
+ "label2id", {label: i for i, label in enumerate(BIOES_LABELS)}
261
  )
262
+ super().__init__(**kwargs)
263
+
264
+
265
+ @dataclass
266
+ class PiiMaskingOutput(ModelOutput):
267
+ """[B, T, 37] tag logits, [B] sensitivity logits, [B, T, H] encoder output."""
268
+
269
+ logits: torch.FloatTensor | None = None
270
+ sensitivity_logits: torch.FloatTensor | None = None
271
+ last_hidden_state: torch.FloatTensor | None = None
272
+
273
+
274
+ class PiiMaskingModel(PreTrainedModel):
275
+ """Bidirectional Qwen3 encoder with PII token and sensitivity heads."""
276
+
277
+ config_class = PiiMaskingConfig
278
+ _supports_sdpa = True
279
+ _supports_flash_attn = True
280
+ # The heads and the decoder biases are fp32 in the checkpoint while the
281
+ # encoder is bf16. Keep them fp32 whichever dtype the model is loaded in.
282
+ _keep_in_fp32_modules_strict = [
283
+ "token_cls_head", "sensitivity_head", "viterbi.b_bias", "viterbi.e_bias",
284
+ ]
285
+
286
+ def __init__(self, config: PiiMaskingConfig):
287
+ super().__init__(config)
288
+ # Encoder architecture from the public backbone repo. The weights come
289
+ # from this checkpoint's backbone.* tensors.
290
+ backbone_config = get_class_from_dynamic_module(
291
+ "configuration.PPLXQwen3Config", BACKBONE_REPO
292
+ ).from_dict(config.backbone)
293
+ backbone_config._attn_implementation = config._attn_implementation
294
+ self.backbone = get_class_from_dynamic_module(
295
+ "modeling.PPLXQwen3Model", BACKBONE_REPO
296
+ )(backbone_config)
297
+ self.token_cls_head = nn.Linear(config.hidden_size, config.num_token_labels)
298
+ self.sensitivity_head = nn.Linear(config.hidden_size, 1)
299
  self.viterbi = ViterbiDecoder(
300
+ BIOES_LABELS, config.viterbi_b_bias, config.viterbi_e_bias
 
 
301
  )
302
+ self._tokenizer = None
303
+ self.post_init()
304
 
305
+ @property
306
+ def tokenizer(self):
307
+ """Tokenizer used by `predict`, loaded from the checkpoint on first use."""
308
+ if self._tokenizer is None:
309
+ self._tokenizer = AutoTokenizer.from_pretrained(self.config._name_or_path)
310
+ return self._tokenizer
311
+
312
+ @tokenizer.setter
313
+ def tokenizer(self, value):
314
+ self._tokenizer = value
315
+
316
+ def forward(
317
+ self,
318
+ input_ids: torch.LongTensor | None = None,
319
+ attention_mask: torch.Tensor | None = None,
320
+ **kwargs,
321
+ ) -> PiiMaskingOutput:
322
  h = self.backbone(
323
+ input_ids=input_ids, attention_mask=attention_mask, **kwargs
324
+ ).last_hidden_state.to(self.token_cls_head.weight.dtype) # [B, T, 1024]
325
+
326
+ if attention_mask is None:
327
+ pooled = h.mean(dim=1)
328
+ else:
329
+ mask = attention_mask.to(h.dtype).unsqueeze(-1)
330
+ pooled = (h * mask).sum(dim=1) / mask.sum(dim=1).clamp(min=1.0)
331
+
332
+ return PiiMaskingOutput(
333
+ logits=self.token_cls_head(h),
334
+ sensitivity_logits=self.sensitivity_head(pooled).squeeze(-1),
335
+ last_hidden_state=h,
336
+ )
337
+
338
+ @torch.no_grad()
339
+ def predict(
340
+ self,
341
+ text: str,
342
+ tokenizer=None,
343
+ max_length: int | None = None,
344
+ ) -> tuple[list[PredictedSpan], float]:
345
+ """Predicted PII spans, and the document sensitivity in [0, 1]."""
346
+ tokenizer = tokenizer if tokenizer is not None else self.tokenizer
347
+ enc = tokenizer(text, return_offsets_mapping=True, return_tensors="pt",
348
+ truncation=True,
349
+ max_length=max_length or self.config.max_seq_len)
350
+ if enc["input_ids"].shape[1] == 0: # empty document, nothing to encode
351
+ return [], 0.0
352
+ out = self(
353
  input_ids=enc["input_ids"].to(self.device),
354
  attention_mask=enc["attention_mask"].to(self.device),
355
+ )
 
 
356
  offsets = [tuple(o) for o in enc["offset_mapping"][0].tolist()]
357
+ spans = self.viterbi.decode(out.logits[0].float().cpu(), offsets, text=text)
358
+ return spans, float(out.sensitivity_logits.float().sigmoid())
359
 
360
+ def mask(self, text: str, placeholder: str = "[{label}]", **kwargs) -> str:
361
+ """The text with every predicted span replaced by e.g. [PRIVATE_EMAIL]."""
362
+ spans, _ = self.predict(text, **kwargs)
363
  for s in sorted(spans, key=lambda s: -s.start):
364
+ filled = placeholder.format(label=s.label.upper())
365
+ text = text[:s.start] + filled + text[s.end:]
366
  return text