edshield 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- edshield/__init__.py +150 -0
- edshield/cli.py +63 -0
- edshield/deid.py +241 -0
- edshield/models.jsonl +6 -0
- edshield/ner.py +276 -0
- edshield/policies/coppa.yaml +29 -0
- edshield/policies/ferpa.yaml +31 -0
- edshield/policies/research.yaml +26 -0
- edshield/rules.py +430 -0
- edshield/service.py +73 -0
- edshield/types.py +108 -0
- edshield-0.2.0.dist-info/METADATA +229 -0
- edshield-0.2.0.dist-info/RECORD +17 -0
- edshield-0.2.0.dist-info/WHEEL +5 -0
- edshield-0.2.0.dist-info/entry_points.txt +2 -0
- edshield-0.2.0.dist-info/licenses/LICENSE +201 -0
- edshield-0.2.0.dist-info/top_level.txt +1 -0
edshield/ner.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Model layer: token-classification models trained on PIILO.
|
|
2
|
+
|
|
3
|
+
Everything here is optional. Models are loaded once and cached per process,
|
|
4
|
+
and only from disk: the local path in models.jsonl, a directory you pass, or
|
|
5
|
+
the Hugging Face cache. Nothing is downloaded unless EDSHIELD_ALLOW_DOWNLOAD=1
|
|
6
|
+
is set or `allow_download=True` is passed; then a model that is not on disk
|
|
7
|
+
is fetched from the Hugging Face Hub by the `hf_id` in models.jsonl.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from functools import lru_cache
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Dict, List, Optional, Sequence, Tuple
|
|
17
|
+
|
|
18
|
+
from .types import Entity
|
|
19
|
+
|
|
20
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
21
|
+
MANIFEST = Path(__file__).resolve().parent / "models.jsonl" # inside the package, so an installed copy has it
|
|
22
|
+
|
|
23
|
+
# Short name -> Hugging Face repo id (or local path). Populated from models.jsonl.
|
|
24
|
+
DEFAULT_MODEL = "piilo_deberta_small"
|
|
25
|
+
|
|
26
|
+
MAX_TOKENS = 1024 # the context length the models are trained with
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ModelUnavailableError(RuntimeError):
|
|
30
|
+
"""The requested model could not be loaded or run."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_manifest() -> dict:
|
|
34
|
+
entries = {}
|
|
35
|
+
if MANIFEST.exists():
|
|
36
|
+
for line in MANIFEST.read_text(encoding="utf-8").splitlines():
|
|
37
|
+
line = line.strip()
|
|
38
|
+
if not line or line.startswith("#"):
|
|
39
|
+
continue
|
|
40
|
+
rec = json.loads(line)
|
|
41
|
+
entries[rec["name"]] = rec
|
|
42
|
+
return entries
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def resolve_model_id(model_name: Optional[str]) -> str:
|
|
46
|
+
"""Map a short name to a repo id or local path; passthrough otherwise."""
|
|
47
|
+
if model_name is None:
|
|
48
|
+
model_name = os.environ.get("EDSHIELD_MODEL", DEFAULT_MODEL)
|
|
49
|
+
if os.path.isdir(model_name):
|
|
50
|
+
return model_name
|
|
51
|
+
rec = load_manifest().get(model_name)
|
|
52
|
+
if rec:
|
|
53
|
+
local = rec.get("local_path")
|
|
54
|
+
if local and os.path.isdir(REPO_ROOT / local):
|
|
55
|
+
return str(REPO_ROOT / local)
|
|
56
|
+
return rec["hf_id"]
|
|
57
|
+
return model_name
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def downloads_allowed() -> bool:
|
|
61
|
+
return os.environ.get("EDSHIELD_ALLOW_DOWNLOAD", "").strip().lower() in {"1", "true", "yes"}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_LOAD_FAILURES: Dict[tuple, Exception] = {}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@lru_cache(maxsize=4)
|
|
68
|
+
def _load(model_id: str, device: str, allow_download: bool):
|
|
69
|
+
try:
|
|
70
|
+
import torch # noqa: WPS433,F401
|
|
71
|
+
from transformers import AutoModelForTokenClassification, AutoTokenizer # noqa: WPS433
|
|
72
|
+
except ImportError as exc: # pragma: no cover
|
|
73
|
+
raise ModelUnavailableError(
|
|
74
|
+
"Model inference needs `pip install 'edshield[hf]'`; rules-only mode is available without it."
|
|
75
|
+
) from exc
|
|
76
|
+
local_only = not allow_download
|
|
77
|
+
tok = AutoTokenizer.from_pretrained(model_id, local_files_only=local_only)
|
|
78
|
+
model = AutoModelForTokenClassification.from_pretrained(model_id, local_files_only=local_only)
|
|
79
|
+
model.to(device).eval()
|
|
80
|
+
return tok, model
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _load_once(model_id: str, device: str, allow_download: bool):
|
|
84
|
+
"""Like _load, but a failure is remembered so it is not retried per call."""
|
|
85
|
+
key = (model_id, device, allow_download)
|
|
86
|
+
if key in _LOAD_FAILURES:
|
|
87
|
+
raise _LOAD_FAILURES[key]
|
|
88
|
+
try:
|
|
89
|
+
return _load(*key)
|
|
90
|
+
except Exception as exc: # noqa: BLE001
|
|
91
|
+
if not isinstance(exc, ModelUnavailableError):
|
|
92
|
+
hint = "" if allow_download else " (downloads are off; set EDSHIELD_ALLOW_DOWNLOAD=1 to fetch it)"
|
|
93
|
+
wrapped = ModelUnavailableError(f"cannot load model '{model_id}'{hint}: {exc}")
|
|
94
|
+
wrapped.__cause__ = exc
|
|
95
|
+
exc = wrapped
|
|
96
|
+
_LOAD_FAILURES[key] = exc
|
|
97
|
+
raise exc
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _predict(chunk: str, model_id: str, device: str, allow_download: bool):
|
|
101
|
+
"""Token offsets, per-token class probabilities and the id->label map for `chunk`."""
|
|
102
|
+
import torch # noqa: WPS433
|
|
103
|
+
|
|
104
|
+
tok, model = _load_once(model_id, device, allow_download)
|
|
105
|
+
enc = tok(chunk, return_offsets_mapping=True, return_tensors="pt")
|
|
106
|
+
if enc["input_ids"].shape[1] > MAX_TOKENS and len(chunk) > 200:
|
|
107
|
+
# Unusually dense text: split at whitespace and run the halves.
|
|
108
|
+
cut = _cut(chunk, len(chunk) // 4, len(chunk) // 2)
|
|
109
|
+
o1, p1, id2label = _predict(chunk[:cut], model_id, device, allow_download)
|
|
110
|
+
o2, p2, _ = _predict(chunk[cut:], model_id, device, allow_download)
|
|
111
|
+
return o1 + [(s + cut, e + cut) for s, e in o2], p1 + p2, id2label
|
|
112
|
+
offsets = [tuple(o) for o in enc.pop("offset_mapping")[0].tolist()]
|
|
113
|
+
with torch.no_grad():
|
|
114
|
+
logits = model(**{k: v.to(model.device) for k, v in enc.items()}).logits[0]
|
|
115
|
+
probs = torch.softmax(logits.float(), dim=-1).cpu().tolist()
|
|
116
|
+
return offsets, probs, dict(model.config.id2label)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# --- Windowing ------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
def _cut(text: str, lo: int, hi: int, prefer_newline: bool = True) -> int:
|
|
122
|
+
"""Best place to cut in (lo, hi]: after a newline, else after whitespace, else hi."""
|
|
123
|
+
nl = text.rfind("\n", lo, hi) if prefer_newline else -1
|
|
124
|
+
if nl != -1:
|
|
125
|
+
return nl + 1
|
|
126
|
+
for i in range(hi, lo, -1):
|
|
127
|
+
if text[i - 1].isspace():
|
|
128
|
+
return i
|
|
129
|
+
return hi
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def windows(text: str, max_chars: int = 2000, overlap: int = 200) -> List[Tuple[int, int, int, int]]:
|
|
133
|
+
"""Split `text` into overlapping windows cut at whitespace.
|
|
134
|
+
|
|
135
|
+
Returns (start, end, keep_from, keep_to) per window. The keep ranges tile
|
|
136
|
+
the text exactly, and each boundary between them sits in the middle of an
|
|
137
|
+
overlap, so whatever is kept from a window was seen with context on both
|
|
138
|
+
sides.
|
|
139
|
+
"""
|
|
140
|
+
n = len(text)
|
|
141
|
+
overlap = max(0, min(overlap, max_chars // 4))
|
|
142
|
+
spans: List[Tuple[int, int]] = []
|
|
143
|
+
pos = 0
|
|
144
|
+
while True:
|
|
145
|
+
end = min(n, pos + max_chars)
|
|
146
|
+
if end < n:
|
|
147
|
+
end = _cut(text, pos + max_chars // 2, end)
|
|
148
|
+
spans.append((pos, end))
|
|
149
|
+
if end >= n:
|
|
150
|
+
break
|
|
151
|
+
back = end - overlap
|
|
152
|
+
pos = max(pos + 1, _cut(text, max(pos, back - overlap), back, prefer_newline=False))
|
|
153
|
+
out = []
|
|
154
|
+
keep_from = 0
|
|
155
|
+
for i, (s, e) in enumerate(spans):
|
|
156
|
+
keep_to = n if i == len(spans) - 1 else (spans[i + 1][0] + e) // 2
|
|
157
|
+
out.append((s, e, keep_from, keep_to))
|
|
158
|
+
keep_from = keep_to
|
|
159
|
+
return out
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# --- Decoding -------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
_JOINERS = " -.'’_"
|
|
165
|
+
_UNBROKEN_LABELS = {"URL_PERSONAL", "EMAIL"}
|
|
166
|
+
_WRAPPERS = "()[]{}<>\"'“”‘’«»"
|
|
167
|
+
NAME_LABELS = ("NAME_STUDENT", "NAME_RELATED")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def decode(
|
|
171
|
+
text: str,
|
|
172
|
+
offsets: Sequence[Tuple[int, int]],
|
|
173
|
+
probs: Sequence[Sequence[float]],
|
|
174
|
+
id2label: Dict[int, str],
|
|
175
|
+
threshold: float = 0.5,
|
|
176
|
+
o_threshold: Optional[float] = None,
|
|
177
|
+
) -> List[Entity]:
|
|
178
|
+
"""Turn per-token probabilities into entity spans.
|
|
179
|
+
|
|
180
|
+
By default a token takes its most likely class and a span is kept when
|
|
181
|
+
its mean probability reaches `threshold`. With `o_threshold` (the recall
|
|
182
|
+
trick from the PIILO competition) a token is an entity whenever
|
|
183
|
+
P(O) < o_threshold, labelled with its most likely non-O class, and the
|
|
184
|
+
span's confidence is the probability that it is an entity at all, 1 - P(O).
|
|
185
|
+
"""
|
|
186
|
+
o_id = next(i for i, l in id2label.items() if l == "O")
|
|
187
|
+
tokens = [] # (start, end, label, score)
|
|
188
|
+
for (s, e), p in zip(offsets, probs):
|
|
189
|
+
if s == e:
|
|
190
|
+
continue # special token
|
|
191
|
+
while s < e and text[s].isspace():
|
|
192
|
+
s += 1
|
|
193
|
+
if s == e:
|
|
194
|
+
continue
|
|
195
|
+
if o_threshold is not None:
|
|
196
|
+
if p[o_id] >= o_threshold:
|
|
197
|
+
continue
|
|
198
|
+
best = max((i for i in range(len(p)) if i != o_id), key=lambda i: p[i])
|
|
199
|
+
score = 1.0 - p[o_id]
|
|
200
|
+
else:
|
|
201
|
+
best = max(range(len(p)), key=lambda i: p[i])
|
|
202
|
+
if best == o_id:
|
|
203
|
+
continue
|
|
204
|
+
score = p[best]
|
|
205
|
+
tokens.append((s, e, id2label[best].split("-", 1)[-1], score))
|
|
206
|
+
|
|
207
|
+
# Join word pieces and adjacent words of the same label.
|
|
208
|
+
groups: List[list] = [] # [start, end, label, [scores]]
|
|
209
|
+
for s, e, label, score in tokens:
|
|
210
|
+
if groups:
|
|
211
|
+
g = groups[-1]
|
|
212
|
+
gap = text[g[1]:s]
|
|
213
|
+
if g[2] == label and len(gap) <= 1 and all(ch in _JOINERS for ch in gap):
|
|
214
|
+
g[1] = max(g[1], e)
|
|
215
|
+
g[3].append(score)
|
|
216
|
+
continue
|
|
217
|
+
groups.append([s, e, label, [score]])
|
|
218
|
+
|
|
219
|
+
ents: List[Entity] = []
|
|
220
|
+
for s, e, label, scores in groups:
|
|
221
|
+
conf = sum(scores) / len(scores)
|
|
222
|
+
if o_threshold is None and conf < threshold:
|
|
223
|
+
continue
|
|
224
|
+
# Cover the whole word: masking half a name leaves the other half behind.
|
|
225
|
+
# A URL or an email is one unbroken run, so a piece of one stands for all of it.
|
|
226
|
+
unbroken = label in _UNBROKEN_LABELS
|
|
227
|
+
while s > 0 and (not text[s - 1].isspace() if unbroken else text[s - 1].isalnum()):
|
|
228
|
+
s -= 1
|
|
229
|
+
while e < len(text) and (not text[e].isspace() if unbroken else text[e].isalnum()):
|
|
230
|
+
e += 1
|
|
231
|
+
if unbroken:
|
|
232
|
+
while s < e and text[s] in _WRAPPERS:
|
|
233
|
+
s += 1
|
|
234
|
+
while e > s and text[e - 1] in _WRAPPERS + ".,;:!?":
|
|
235
|
+
e -= 1
|
|
236
|
+
if label in NAME_LABELS and e - s < 2:
|
|
237
|
+
continue # an initial ("By C.") is not a name
|
|
238
|
+
if ents and ents[-1].label == label and s <= ents[-1].end:
|
|
239
|
+
ents[-1].end = max(ents[-1].end, e)
|
|
240
|
+
ents[-1].text = text[ents[-1].start:ents[-1].end]
|
|
241
|
+
ents[-1].confidence = min(ents[-1].confidence, conf)
|
|
242
|
+
continue
|
|
243
|
+
ents.append(Entity(label=label, text=text[s:e], start=s, end=e, confidence=conf, source="model"))
|
|
244
|
+
return ents
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def detect_model(
|
|
248
|
+
text: str,
|
|
249
|
+
model_name: Optional[str] = None,
|
|
250
|
+
device: str = "cpu",
|
|
251
|
+
threshold: float = 0.5,
|
|
252
|
+
o_threshold: Optional[float] = None,
|
|
253
|
+
max_chars: int = 2000,
|
|
254
|
+
overlap: int = 200,
|
|
255
|
+
allow_download: Optional[bool] = None,
|
|
256
|
+
) -> List[Entity]:
|
|
257
|
+
"""Run a token-classification model over `text` in overlapping windows.
|
|
258
|
+
|
|
259
|
+
See `decode` for `threshold` and `o_threshold`. Raises
|
|
260
|
+
ModelUnavailableError if the model cannot be loaded.
|
|
261
|
+
"""
|
|
262
|
+
model_id = resolve_model_id(model_name)
|
|
263
|
+
if allow_download is None:
|
|
264
|
+
allow_download = downloads_allowed()
|
|
265
|
+
ents: List[Entity] = []
|
|
266
|
+
for start, end, keep_from, keep_to in windows(text, max_chars, overlap):
|
|
267
|
+
chunk = text[start:end]
|
|
268
|
+
if not chunk.strip():
|
|
269
|
+
continue
|
|
270
|
+
offsets, probs, id2label = _predict(chunk, model_id, device, allow_download)
|
|
271
|
+
for e in decode(chunk, offsets, probs, id2label, threshold, o_threshold):
|
|
272
|
+
e.start += start
|
|
273
|
+
e.end += start
|
|
274
|
+
if keep_from <= (e.start + e.end) // 2 < keep_to:
|
|
275
|
+
ents.append(e)
|
|
276
|
+
return ents
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# COPPA profile for services used by children under 13.
|
|
2
|
+
# COPPA (16 CFR 312.2) treats persistent identifiers (usernames, screen
|
|
3
|
+
# names, device IDs), contact information and geolocation as personal
|
|
4
|
+
# information. This profile is recall-first: lower confidence thresholds,
|
|
5
|
+
# usernames and IDs always removed, and no surrogate generation for
|
|
6
|
+
# contact fields (a fake email is still an email a child might use).
|
|
7
|
+
name: coppa
|
|
8
|
+
description: Recall-first removal of children's personal information from chat, transcripts and submissions before any model call.
|
|
9
|
+
default_method: mask
|
|
10
|
+
min_confidence: 0.35
|
|
11
|
+
allow_method_override: true
|
|
12
|
+
labels:
|
|
13
|
+
NAME_STUDENT: {enabled: true, mask: "[CHILD]", min_confidence: 0.3}
|
|
14
|
+
EMAIL: {enabled: true, method: mask}
|
|
15
|
+
USERNAME: {enabled: true, method: mask, min_confidence: 0.3}
|
|
16
|
+
ID_NUM: {enabled: true, min_confidence: 0.5}
|
|
17
|
+
PHONE_NUM: {enabled: true, method: mask}
|
|
18
|
+
URL_PERSONAL: {enabled: true, method: mask}
|
|
19
|
+
STREET_ADDRESS: {enabled: true, method: mask}
|
|
20
|
+
SSN: {enabled: true, method: mask}
|
|
21
|
+
DATE: {enabled: true, method: shift_dates}
|
|
22
|
+
# Persistent identifiers and geolocation (16 CFR 312.2), and the people and places around the child.
|
|
23
|
+
NAME_RELATED: {enabled: true, mask: "[PERSON]", min_confidence: 0.3}
|
|
24
|
+
SCHOOL: {enabled: true, method: mask}
|
|
25
|
+
LOCATION: {enabled: true, method: mask}
|
|
26
|
+
AGE: {enabled: true, method: mask}
|
|
27
|
+
IP_ADDRESS: {enabled: true, method: mask}
|
|
28
|
+
DEVICE_ID: {enabled: true, method: mask}
|
|
29
|
+
GEO: {enabled: true, method: mask}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# FERPA de-identification profile.
|
|
2
|
+
# FERPA (34 CFR 99.31(b)) permits release of education records only after
|
|
3
|
+
# removal of all personally identifiable information, including information
|
|
4
|
+
# that "alone or in combination" would let a reasonable person in the school
|
|
5
|
+
# community identify the student. This profile therefore acts on every
|
|
6
|
+
# direct identifier and on dates. Using this profile does not, by itself,
|
|
7
|
+
# establish FERPA compliance; a reasonable-determination review is still
|
|
8
|
+
# the institution's responsibility.
|
|
9
|
+
name: ferpa
|
|
10
|
+
description: Remove all direct identifiers from student writing and records before release or before sending to a third-party model.
|
|
11
|
+
default_method: mask
|
|
12
|
+
min_confidence: 0.5
|
|
13
|
+
allow_method_override: true
|
|
14
|
+
labels:
|
|
15
|
+
NAME_STUDENT: {enabled: true, mask: "[STUDENT]", min_confidence: 0.4}
|
|
16
|
+
EMAIL: {enabled: true}
|
|
17
|
+
USERNAME: {enabled: true}
|
|
18
|
+
ID_NUM: {enabled: true, min_confidence: 0.6}
|
|
19
|
+
PHONE_NUM: {enabled: true}
|
|
20
|
+
URL_PERSONAL: {enabled: true}
|
|
21
|
+
STREET_ADDRESS: {enabled: true}
|
|
22
|
+
SSN: {enabled: true, method: mask}
|
|
23
|
+
DATE: {enabled: true, method: shift_dates}
|
|
24
|
+
# Family members' names and indirect identifiers (34 CFR 99.3).
|
|
25
|
+
NAME_RELATED: {enabled: true, mask: "[PERSON]", min_confidence: 0.4}
|
|
26
|
+
SCHOOL: {enabled: true, method: mask}
|
|
27
|
+
LOCATION: {enabled: true, method: mask}
|
|
28
|
+
AGE: {enabled: true, method: mask}
|
|
29
|
+
IP_ADDRESS: {enabled: true, method: mask}
|
|
30
|
+
DEVICE_ID: {enabled: true, method: mask}
|
|
31
|
+
GEO: {enabled: true, method: mask}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Research-release profile: readable surrogates instead of masks so that
|
|
2
|
+
# learning-science analyses (essay scoring, discourse tagging) keep natural
|
|
3
|
+
# text. Same value -> same surrogate within a document. Dates are shifted by
|
|
4
|
+
# a per-document offset so intervals are preserved.
|
|
5
|
+
name: research
|
|
6
|
+
description: Surrogate replacement that keeps text natural for educational-data-mining releases.
|
|
7
|
+
default_method: replace
|
|
8
|
+
min_confidence: 0.5
|
|
9
|
+
allow_method_override: true
|
|
10
|
+
labels:
|
|
11
|
+
NAME_STUDENT: {enabled: true}
|
|
12
|
+
EMAIL: {enabled: true}
|
|
13
|
+
USERNAME: {enabled: true}
|
|
14
|
+
ID_NUM: {enabled: true}
|
|
15
|
+
PHONE_NUM: {enabled: true}
|
|
16
|
+
URL_PERSONAL: {enabled: true}
|
|
17
|
+
STREET_ADDRESS: {enabled: true}
|
|
18
|
+
SSN: {enabled: true, method: mask}
|
|
19
|
+
DATE: {enabled: true, method: shift_dates}
|
|
20
|
+
NAME_RELATED: {enabled: true}
|
|
21
|
+
SCHOOL: {enabled: true, method: mask}
|
|
22
|
+
LOCATION: {enabled: true, method: mask}
|
|
23
|
+
AGE: {enabled: true, method: mask}
|
|
24
|
+
IP_ADDRESS: {enabled: true, method: mask}
|
|
25
|
+
DEVICE_ID: {enabled: true, method: mask}
|
|
26
|
+
GEO: {enabled: true, method: mask}
|