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/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}