rcn787 commited on
Commit
8000b36
·
verified ·
1 Parent(s): 4637b7c

initial mlx conversion of rampart pii ner

Browse files
Files changed (9) hide show
  1. README.md +87 -0
  2. config.json +99 -0
  3. demo.py +55 -0
  4. model.safetensors +3 -0
  5. rampart_mlx.py +132 -0
  6. special_tokens_map.json +37 -0
  7. tokenizer.json +0 -0
  8. tokenizer_config.json +58 -0
  9. vocab.txt +0 -0
README.md ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: cc-by-4.0
3
+ language:
4
+ - en
5
+ library_name: mlx
6
+ pipeline_tag: token-classification
7
+ tags:
8
+ - mlx
9
+ - bert
10
+ - token-classification
11
+ - pii
12
+ - ner
13
+ - privacy
14
+ base_model: nationaldesignstudio/rampart
15
+ ---
16
+
17
+ # Rampart PII NER — MLX
18
+
19
+ An MLX build of **Rampart**, a compact encoder-only BERT (MiniLM-L6, hidden 384,
20
+ 6 layers, ~18.5M params) with a 35-label BIO token-classification head for
21
+ detecting personally identifiable information (PII). Intended for on-device,
22
+ client-side PII redaction on Apple Silicon.
23
+
24
+ This repository ships **float (fp16) MLX weights** in `model.safetensors` plus a
25
+ small self-contained MLX implementation (`rampart_mlx.py`).
26
+
27
+ ## Provenance
28
+
29
+ This is an **independent MLX conversion** of the original
30
+ [`nationaldesignstudio/rampart`](https://huggingface.co/nationaldesignstudio/rampart).
31
+ The original is distributed as a 4-bit quantized ONNX export; the float weights
32
+ here were recovered directly from that export (4-bit `MatMulNBits` linears and
33
+ INT8 embeddings dequantized to float) and then stored in MLX `safetensors`.
34
+
35
+ The conversion was verified to reproduce the original ONNX model **exactly**:
36
+ on the validation prompts, MLX vs. ONNX Runtime token-label agreement is
37
+ **100%** with a maximum logit difference of ~1e-5 (floating-point rounding).
38
+
39
+ No third-party MLX port was used in producing these weights.
40
+
41
+ ## Labels
42
+
43
+ 17 entity types in BIO format (35 classes incl. `O`): `GIVEN_NAME`, `SURNAME`,
44
+ `EMAIL`, `PHONE`, `URL`, `TAX_ID`, `BANK_ACCOUNT`, `ROUTING_NUMBER`,
45
+ `GOVERNMENT_ID`, `PASSPORT`, `DRIVERS_LICENSE`, `BUILDING_NUMBER`, `STREET_NAME`,
46
+ `SECONDARY_ADDRESS`, `CITY`, `STATE`, `ZIP_CODE`.
47
+
48
+ ## Usage
49
+
50
+ ```bash
51
+ pip install mlx tokenizers
52
+ python demo.py "My name is John Smith, email john.smith@example.com"
53
+ ```
54
+
55
+ ```python
56
+ import mlx.core as mx
57
+ from tokenizers import Tokenizer
58
+ from rampart_mlx import load
59
+
60
+ model, cfg = load(".")
61
+ tok = Tokenizer.from_file("tokenizer.json")
62
+ enc = tok.encode("Call me at (555) 123-4567")
63
+ logits = model(mx.array([enc.ids]), mx.array([enc.attention_mask]))
64
+ label_ids = mx.argmax(logits[0], axis=-1).tolist()
65
+ labels = [cfg.id2label[i] for i in label_ids]
66
+ ```
67
+
68
+ See `demo.py` for BIO span decoding using the tokenizer's char offsets (needed to
69
+ map predicted labels back onto the original text for redaction).
70
+
71
+ ## Files
72
+
73
+ | File | Purpose |
74
+ |------|---------|
75
+ | `model.safetensors` | fp16 MLX weights (HuggingFace-style key names) |
76
+ | `config.json` | model architecture + `id2label` |
77
+ | `rampart_mlx.py` | self-contained MLX model + loader |
78
+ | `demo.py` | tokenize → infer → decode spans |
79
+ | `tokenizer.json`, `vocab.txt`, `tokenizer_config.json`, `special_tokens_map.json` | WordPiece tokenizer |
80
+
81
+ ## License & attribution
82
+
83
+ Released under **CC-BY-4.0**, the same license as the upstream model. Attribution:
84
+
85
+ - Original model: [`nationaldesignstudio/rampart`](https://huggingface.co/nationaldesignstudio/rampart) (CC-BY-4.0)
86
+ - Base encoder: [`nreimers/MiniLM-L6-H384-uncased`](https://huggingface.co/nreimers/MiniLM-L6-H384-uncased)
87
+ - Training data: [`ai4privacy/pii-masking-openpii-1.5m`](https://huggingface.co/datasets/ai4privacy/pii-masking-openpii-1.5m) (CC-BY-4.0)
config.json ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "BertForTokenClassification"
4
+ ],
5
+ "attention_probs_dropout_prob": 0.1,
6
+ "classifier_dropout": null,
7
+ "dtype": "float32",
8
+ "gradient_checkpointing": false,
9
+ "hidden_act": "gelu",
10
+ "hidden_dropout_prob": 0.1,
11
+ "hidden_size": 384,
12
+ "id2label": {
13
+ "0": "O",
14
+ "1": "B-GIVEN_NAME",
15
+ "2": "I-GIVEN_NAME",
16
+ "3": "B-SURNAME",
17
+ "4": "I-SURNAME",
18
+ "5": "B-EMAIL",
19
+ "6": "I-EMAIL",
20
+ "7": "B-PHONE",
21
+ "8": "I-PHONE",
22
+ "9": "B-URL",
23
+ "10": "I-URL",
24
+ "11": "B-TAX_ID",
25
+ "12": "I-TAX_ID",
26
+ "13": "B-BANK_ACCOUNT",
27
+ "14": "I-BANK_ACCOUNT",
28
+ "15": "B-ROUTING_NUMBER",
29
+ "16": "I-ROUTING_NUMBER",
30
+ "17": "B-GOVERNMENT_ID",
31
+ "18": "I-GOVERNMENT_ID",
32
+ "19": "B-PASSPORT",
33
+ "20": "I-PASSPORT",
34
+ "21": "B-DRIVERS_LICENSE",
35
+ "22": "I-DRIVERS_LICENSE",
36
+ "23": "B-BUILDING_NUMBER",
37
+ "24": "I-BUILDING_NUMBER",
38
+ "25": "B-STREET_NAME",
39
+ "26": "I-STREET_NAME",
40
+ "27": "B-SECONDARY_ADDRESS",
41
+ "28": "I-SECONDARY_ADDRESS",
42
+ "29": "B-CITY",
43
+ "30": "I-CITY",
44
+ "31": "B-STATE",
45
+ "32": "I-STATE",
46
+ "33": "B-ZIP_CODE",
47
+ "34": "I-ZIP_CODE"
48
+ },
49
+ "initializer_range": 0.02,
50
+ "intermediate_size": 1536,
51
+ "label2id": {
52
+ "B-BANK_ACCOUNT": 13,
53
+ "B-BUILDING_NUMBER": 23,
54
+ "B-CITY": 29,
55
+ "B-DRIVERS_LICENSE": 21,
56
+ "B-EMAIL": 5,
57
+ "B-GIVEN_NAME": 1,
58
+ "B-GOVERNMENT_ID": 17,
59
+ "B-PASSPORT": 19,
60
+ "B-PHONE": 7,
61
+ "B-ROUTING_NUMBER": 15,
62
+ "B-SECONDARY_ADDRESS": 27,
63
+ "B-STATE": 31,
64
+ "B-STREET_NAME": 25,
65
+ "B-SURNAME": 3,
66
+ "B-TAX_ID": 11,
67
+ "B-URL": 9,
68
+ "B-ZIP_CODE": 33,
69
+ "I-BANK_ACCOUNT": 14,
70
+ "I-BUILDING_NUMBER": 24,
71
+ "I-CITY": 30,
72
+ "I-DRIVERS_LICENSE": 22,
73
+ "I-EMAIL": 6,
74
+ "I-GIVEN_NAME": 2,
75
+ "I-GOVERNMENT_ID": 18,
76
+ "I-PASSPORT": 20,
77
+ "I-PHONE": 8,
78
+ "I-ROUTING_NUMBER": 16,
79
+ "I-SECONDARY_ADDRESS": 28,
80
+ "I-STATE": 32,
81
+ "I-STREET_NAME": 26,
82
+ "I-SURNAME": 4,
83
+ "I-TAX_ID": 12,
84
+ "I-URL": 10,
85
+ "I-ZIP_CODE": 34,
86
+ "O": 0
87
+ },
88
+ "layer_norm_eps": 1e-12,
89
+ "max_position_embeddings": 512,
90
+ "model_type": "bert",
91
+ "num_attention_heads": 12,
92
+ "num_hidden_layers": 6,
93
+ "pad_token_id": 0,
94
+ "position_embedding_type": "absolute",
95
+ "transformers_version": "4.57.6",
96
+ "type_vocab_size": 2,
97
+ "use_cache": true,
98
+ "vocab_size": 19730
99
+ }
demo.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Minimal Rampart-MLX demo: tokenize, run the model, decode BIO spans.
3
+
4
+ python demo.py "My name is John Smith, email john@example.com"
5
+ """
6
+
7
+ import sys
8
+
9
+ import mlx.core as mx
10
+ from tokenizers import Tokenizer
11
+
12
+ from rampart_mlx import load
13
+
14
+
15
+ def decode_spans(tokens, offsets, label_ids, id2label):
16
+ """Merge B-/I- runs into (entity_type, text, start, end) spans."""
17
+ spans, cur = [], None
18
+ for (start, end), lid in zip(offsets, label_ids):
19
+ if start == end: # special token
20
+ continue
21
+ label = id2label[lid]
22
+ if label == "O":
23
+ cur = None
24
+ continue
25
+ tag, etype = label[0], label[2:]
26
+ if tag == "B" or cur is None or cur["type"] != etype:
27
+ cur = {"type": etype, "start": start, "end": end}
28
+ spans.append(cur)
29
+ else:
30
+ cur["end"] = end
31
+ return spans
32
+
33
+
34
+ def main():
35
+ text = sys.argv[1] if len(sys.argv) > 1 else \
36
+ "My name is John Smith and my email is john.smith@example.com"
37
+ model, cfg = load(".")
38
+ tok = Tokenizer.from_file("tokenizer.json")
39
+ enc = tok.encode(text)
40
+
41
+ input_ids = mx.array([enc.ids])
42
+ attention_mask = mx.array([enc.attention_mask])
43
+ logits = model(input_ids, attention_mask)
44
+ label_ids = mx.argmax(logits[0], axis=-1).tolist()
45
+
46
+ spans = decode_spans(enc.tokens, enc.offsets, label_ids, cfg.id2label)
47
+ print(f"\nText: {text}\n")
48
+ if not spans:
49
+ print("No PII detected.")
50
+ for s in spans:
51
+ print(f" {s['type']:16s} {text[s['start']:s['end']]!r} [{s['start']}:{s['end']}]")
52
+
53
+
54
+ if __name__ == "__main__":
55
+ main()
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5a2120db5b6789b9c4bd68dd3bc133567a4cb80835cad8bb6feae83edb43bbcf
3
+ size 36881270
rampart_mlx.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Rampart PII NER — MLX implementation.
3
+
4
+ A standard encoder-only BERT (MiniLM-L6, hidden 384) with a 35-label BIO
5
+ token-classification head for PII detection. Weights are float (fp16) and load
6
+ directly from `model.safetensors`.
7
+
8
+ Independent MLX conversion: float weights were recovered 1:1 from the original
9
+ `nationaldesignstudio/rampart` 4-bit ONNX export (verified to match the ONNX
10
+ runtime output exactly). See README for attribution.
11
+ """
12
+
13
+ import json
14
+ import math
15
+ from pathlib import Path
16
+
17
+ import mlx.core as mx
18
+ import mlx.nn as nn
19
+
20
+
21
+ class BertConfig:
22
+ def __init__(self, path):
23
+ d = json.loads(Path(path).read_text())
24
+ self.hidden_size = d["hidden_size"]
25
+ self.num_hidden_layers = d["num_hidden_layers"]
26
+ self.num_attention_heads = d["num_attention_heads"]
27
+ self.intermediate_size = d["intermediate_size"]
28
+ self.vocab_size = d["vocab_size"]
29
+ self.max_position_embeddings = d["max_position_embeddings"]
30
+ self.type_vocab_size = d["type_vocab_size"]
31
+ self.layer_norm_eps = d["layer_norm_eps"]
32
+ self.id2label = {int(k): v for k, v in d["id2label"].items()}
33
+ self.num_labels = len(self.id2label)
34
+
35
+
36
+ class BertLayer(nn.Module):
37
+ def __init__(self, c):
38
+ super().__init__()
39
+ H, I = c.hidden_size, c.intermediate_size
40
+ self.query = nn.Linear(H, H)
41
+ self.key = nn.Linear(H, H)
42
+ self.value = nn.Linear(H, H)
43
+ self.attn_out = nn.Linear(H, H)
44
+ self.attn_ln = nn.LayerNorm(H, eps=c.layer_norm_eps)
45
+ self.intermediate = nn.Linear(H, I)
46
+ self.output = nn.Linear(I, H)
47
+ self.output_ln = nn.LayerNorm(H, eps=c.layer_norm_eps)
48
+ self.n_heads = c.num_attention_heads
49
+ self.d_head = H // c.num_attention_heads
50
+
51
+ def __call__(self, x, add_mask):
52
+ B, S, _ = x.shape
53
+
54
+ def split(t):
55
+ return t.reshape(B, S, self.n_heads, self.d_head).transpose(0, 2, 1, 3)
56
+
57
+ q, k, v = split(self.query(x)), split(self.key(x)), split(self.value(x))
58
+ scores = (q @ k.transpose(0, 1, 3, 2)) / math.sqrt(self.d_head) + add_mask
59
+ ctx = (mx.softmax(scores, axis=-1) @ v).transpose(0, 2, 1, 3).reshape(B, S, -1)
60
+ x = self.attn_ln(self.attn_out(ctx) + x)
61
+ h = nn.gelu(self.intermediate(x))
62
+ return self.output_ln(self.output(h) + x)
63
+
64
+
65
+ class RampartForTokenClassification(nn.Module):
66
+ def __init__(self, c):
67
+ super().__init__()
68
+ self.config = c
69
+ self.word_embeddings = nn.Embedding(c.vocab_size, c.hidden_size)
70
+ self.position_embeddings = nn.Embedding(c.max_position_embeddings, c.hidden_size)
71
+ self.token_type_embeddings = nn.Embedding(c.type_vocab_size, c.hidden_size)
72
+ self.embeddings_ln = nn.LayerNorm(c.hidden_size, eps=c.layer_norm_eps)
73
+ self.layers = [BertLayer(c) for _ in range(c.num_hidden_layers)]
74
+ self.classifier = nn.Linear(c.hidden_size, c.num_labels)
75
+
76
+ def __call__(self, input_ids, attention_mask, token_type_ids=None):
77
+ B, S = input_ids.shape
78
+ if token_type_ids is None:
79
+ token_type_ids = mx.zeros((B, S), dtype=input_ids.dtype)
80
+ pos_ids = mx.arange(S)[None, :]
81
+ x = self.embeddings_ln(
82
+ self.word_embeddings(input_ids)
83
+ + self.position_embeddings(pos_ids)
84
+ + self.token_type_embeddings(token_type_ids)
85
+ )
86
+ add_mask = (1.0 - attention_mask.astype(mx.float32))[:, None, None, :] * -1e9
87
+ for layer in self.layers:
88
+ x = layer(x, add_mask)
89
+ return self.classifier(x)
90
+
91
+
92
+ # Maps the HuggingFace-style safetensors keys onto this module tree.
93
+ def _remap(weights, n_layers):
94
+ out = {}
95
+ direct = {
96
+ "bert.embeddings.word_embeddings.weight": "word_embeddings.weight",
97
+ "bert.embeddings.position_embeddings.weight": "position_embeddings.weight",
98
+ "bert.embeddings.token_type_embeddings.weight": "token_type_embeddings.weight",
99
+ "bert.embeddings.LayerNorm.weight": "embeddings_ln.weight",
100
+ "bert.embeddings.LayerNorm.bias": "embeddings_ln.bias",
101
+ "classifier.weight": "classifier.weight",
102
+ "classifier.bias": "classifier.bias",
103
+ }
104
+ for src, dst in direct.items():
105
+ out[dst] = weights[src]
106
+ for i in range(n_layers):
107
+ p = f"bert.encoder.layer.{i}"
108
+ m = {
109
+ f"{p}.attention.self.query": f"layers.{i}.query",
110
+ f"{p}.attention.self.key": f"layers.{i}.key",
111
+ f"{p}.attention.self.value": f"layers.{i}.value",
112
+ f"{p}.attention.output.dense": f"layers.{i}.attn_out",
113
+ f"{p}.attention.output.LayerNorm": f"layers.{i}.attn_ln",
114
+ f"{p}.intermediate.dense": f"layers.{i}.intermediate",
115
+ f"{p}.output.dense": f"layers.{i}.output",
116
+ f"{p}.output.LayerNorm": f"layers.{i}.output_ln",
117
+ }
118
+ for src, dst in m.items():
119
+ out[f"{dst}.weight"] = weights[f"{src}.weight"]
120
+ out[f"{dst}.bias"] = weights[f"{src}.bias"]
121
+ return out
122
+
123
+
124
+ def load(model_dir):
125
+ model_dir = Path(model_dir)
126
+ c = BertConfig(model_dir / "config.json")
127
+ model = RampartForTokenClassification(c)
128
+ weights = mx.load(str(model_dir / "model.safetensors"))
129
+ flat = _remap(weights, c.num_hidden_layers)
130
+ model.load_weights(list(flat.items()))
131
+ mx.eval(model.parameters())
132
+ return model, c
special_tokens_map.json ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cls_token": {
3
+ "content": "[CLS]",
4
+ "lstrip": false,
5
+ "normalized": false,
6
+ "rstrip": false,
7
+ "single_word": false
8
+ },
9
+ "mask_token": {
10
+ "content": "[MASK]",
11
+ "lstrip": false,
12
+ "normalized": false,
13
+ "rstrip": false,
14
+ "single_word": false
15
+ },
16
+ "pad_token": {
17
+ "content": "[PAD]",
18
+ "lstrip": false,
19
+ "normalized": false,
20
+ "rstrip": false,
21
+ "single_word": false
22
+ },
23
+ "sep_token": {
24
+ "content": "[SEP]",
25
+ "lstrip": false,
26
+ "normalized": false,
27
+ "rstrip": false,
28
+ "single_word": false
29
+ },
30
+ "unk_token": {
31
+ "content": "[UNK]",
32
+ "lstrip": false,
33
+ "normalized": false,
34
+ "rstrip": false,
35
+ "single_word": false
36
+ }
37
+ }
tokenizer.json ADDED
The diff for this file is too large to render. See raw diff
 
tokenizer_config.json ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "added_tokens_decoder": {
3
+ "0": {
4
+ "content": "[PAD]",
5
+ "lstrip": false,
6
+ "normalized": false,
7
+ "rstrip": false,
8
+ "single_word": false,
9
+ "special": true
10
+ },
11
+ "1": {
12
+ "content": "[UNK]",
13
+ "lstrip": false,
14
+ "normalized": false,
15
+ "rstrip": false,
16
+ "single_word": false,
17
+ "special": true
18
+ },
19
+ "2": {
20
+ "content": "[CLS]",
21
+ "lstrip": false,
22
+ "normalized": false,
23
+ "rstrip": false,
24
+ "single_word": false,
25
+ "special": true
26
+ },
27
+ "3": {
28
+ "content": "[SEP]",
29
+ "lstrip": false,
30
+ "normalized": false,
31
+ "rstrip": false,
32
+ "single_word": false,
33
+ "special": true
34
+ },
35
+ "4": {
36
+ "content": "[MASK]",
37
+ "lstrip": false,
38
+ "normalized": false,
39
+ "rstrip": false,
40
+ "single_word": false,
41
+ "special": true
42
+ }
43
+ },
44
+ "clean_up_tokenization_spaces": true,
45
+ "cls_token": "[CLS]",
46
+ "do_basic_tokenize": true,
47
+ "do_lower_case": true,
48
+ "extra_special_tokens": {},
49
+ "mask_token": "[MASK]",
50
+ "model_max_length": 1000000000000000019884624838656,
51
+ "never_split": null,
52
+ "pad_token": "[PAD]",
53
+ "sep_token": "[SEP]",
54
+ "strip_accents": null,
55
+ "tokenize_chinese_chars": true,
56
+ "tokenizer_class": "BertTokenizer",
57
+ "unk_token": "[UNK]"
58
+ }
vocab.txt ADDED
The diff for this file is too large to render. See raw diff