File size: 7,027 Bytes
a05a9b8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
"""
NERPA – Text anonymisation using the fine-tuned GLiNER2 model.

Usage:
    python anonymise.py "My name is John Smith, born 15/03/1990. Email: john@example.com"
    python anonymise.py --file input.txt
    python anonymise.py --file input.txt --output anonymised.txt
"""

import argparse
import sys
from typing import Dict, List, Tuple

import torch
from gliner2 import GLiNER2


# Entity types the model was fine-tuned to recognise, with descriptions
# that guide the bi-encoder towards better detection.
PII_ENTITIES = {
    "LOCATION": "Address, country, city, postcode, street, any other location",
    "AGE": "Age of a person",
    "DIGITAL_KEYS": "Digital keys, passwords, pins used to access anything like servers, banks, APIs, accounts etc",
    "BANK_ACCOUNT_DETAILS": "Bank account details such as number, IBAN, SWIFT, routing numbers etc",
    "CARD_DETAILS": "Debit or credit card details such as card number, CVV, expiration etc",
    "DATE_TIME": "Generic date and time",
    "DATE_OF_BIRTH": "Date of birth",
    "PERSONAL_ID_NUMBERS": "Common personal identification numbers such as passport numbers, driving licenses, taxpayer and insurance numbers",
    "TECHNICAL_ID_NUMBERS": "IP and MAC addresses, serial numbers and any other technical ID numbers",
    "EMAIL": "Email",
    "PERSON_NAME": "Person name",
    "BUSINESS_NAME": "Business name",
    "PHONE": "Any personal or other phone numbers",
    "URL": "Any short or full URL",
    "USERNAME": "Username",
    "VEHICLE_ID_NUMBERS": "Any vehicle numbers like license plates, vehicle identification numbers",
}

CONFIDENCE_THRESHOLD = 0.25
CHUNK_SIZE = 3000
CHUNK_OVERLAP = 100


def load_model(model_path: str = ".") -> GLiNER2:
    """Load the NERPA model onto the best available device."""
    if torch.cuda.is_available():
        device = torch.device("cuda")
    elif torch.backends.mps.is_available():
        device = torch.device("mps")
    else:
        device = torch.device("cpu")

    model = GLiNER2.from_pretrained(model_path)
    model.to(device)
    return model


def chunk_text(text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> Tuple[List[str], List[int]]:
    """Split text into overlapping chunks, returning chunks and their start offsets."""
    if not text:
        return [], []
    chunks, starts = [], []
    step = chunk_size - overlap
    pos = 0
    while pos < len(text):
        chunks.append(text[pos : pos + chunk_size])
        starts.append(pos)
        if pos + chunk_size >= len(text):
            break
        pos += step
    return chunks, starts


def detect_entities(
    model: GLiNER2,
    text: str,
    entities: Dict[str, str] = None,
    threshold: float = CONFIDENCE_THRESHOLD,
) -> List[dict]:
    """
    Detect PII entities in text, returning a list of
    {"type": str, "start": int, "end": int, "score": float} dicts
    with character offsets into the original text.
    """
    entities = entities or PII_ENTITIES

    # Always detect both date types so the model can disambiguate
    detect = dict(entities)
    if "DATE_TIME" in detect and "DATE_OF_BIRTH" not in detect:
        detect["DATE_OF_BIRTH"] = PII_ENTITIES["DATE_OF_BIRTH"]
    elif "DATE_OF_BIRTH" in detect and "DATE_TIME" not in detect:
        detect["DATE_TIME"] = PII_ENTITIES["DATE_TIME"]

    chunks, offsets = chunk_text(text)

    all_chunk_results = []
    for batch_start in range(0, len(chunks), 32):
        batch = chunks[batch_start : batch_start + 32]
        results = model.batch_extract_entities(
            batch,
            detect,
            include_confidence=True,
            include_spans=True,
            threshold=threshold,
        )
        all_chunk_results.extend(results)

    # Merge results across chunks: de-duplicate overlapping detections
    seen: Dict[Tuple[int, int], dict] = {}
    for chunk_result, chunk_offset in zip(all_chunk_results, offsets):
        for label, occurrences in chunk_result["entities"].items():
            for occ in occurrences:
                start = occ["start"] + chunk_offset
                end = occ["end"] + chunk_offset
                pos = (start, end)
                if pos not in seen or seen[pos]["score"] < occ["confidence"]:
                    seen[pos] = {"type": label, "score": occ["confidence"]}

    # Merge overlapping spans, keeping highest confidence label
    items = sorted(
        [(s, e, info) for (s, e), info in seen.items() if info["type"] in entities],
        key=lambda x: (x[0], x[1]),
    )
    if not items:
        return []

    merged = []
    cur_s, cur_e, cur_info = items[0]
    for s, e, info in items[1:]:
        if s < cur_e:  # overlapping
            cur_e = max(cur_e, e)
            if info["score"] > cur_info["score"]:
                cur_info = info
        else:
            merged.append({"type": cur_info["type"], "start": cur_s, "end": cur_e, "score": cur_info["score"]})
            cur_s, cur_e, cur_info = s, e, info
    merged.append({"type": cur_info["type"], "start": cur_s, "end": cur_e, "score": cur_info["score"]})

    return merged


def anonymise(text: str, detected: List[dict]) -> str:
    """Replace detected entities with placeholders like [PERSON_NAME]."""
    # Process from end to start so offsets stay valid
    result = text
    for entity in sorted(detected, key=lambda e: e["start"], reverse=True):
        placeholder = f'[{entity["type"]}]'
        result = result[: entity["start"]] + placeholder + result[entity["end"] :]
    return result


def main():
    parser = argparse.ArgumentParser(description="Anonymise PII in text using the NERPA model.")
    parser.add_argument("text", nargs="?", help="Text to anonymise (or use --file)")
    parser.add_argument("--file", "-f", help="Read text from a file instead")
    parser.add_argument("--output", "-o", help="Write anonymised text to file (default: stdout)")
    parser.add_argument("--model", "-m", default=".", help="Path to model directory (default: current dir)")
    parser.add_argument("--threshold", "-t", type=float, default=CONFIDENCE_THRESHOLD, help="Confidence threshold (default: 0.25)")
    parser.add_argument("--show-entities", action="store_true", help="Print detected entities before anonymised text")
    args = parser.parse_args()

    if args.file:
        with open(args.file) as f:
            text = f.read()
    elif args.text:
        text = args.text
    else:
        parser.error("Provide text as an argument or use --file")

    model = load_model(args.model)
    detected = detect_entities(model, text, threshold=args.threshold)

    if args.show_entities:
        for e in detected:
            print(f'  {e["type"]:25s} [{e["start"]:5d}:{e["end"]:5d}] (score={e["score"]:.2f})  "{text[e["start"]:e["end"]]}"', file=sys.stderr)
        print(file=sys.stderr)

    result = anonymise(text, detected)

    if args.output:
        with open(args.output, "w") as f:
            f.write(result)
    else:
        print(result)


if __name__ == "__main__":
    main()