pytextad 0.1.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.
- pytextad/__init__.py +9 -0
- pytextad/models/__init__.py +7 -0
- pytextad/models/base.py +71 -0
- pytextad/models/cvdd.py +221 -0
- pytextad/models/date.py +270 -0
- pytextad/models/fate.py +171 -0
- pytextad/models/rsrae.py +213 -0
- pytextad/utils/__init__.py +3 -0
- pytextad/utils/embeddings.py +98 -0
- pytextad/version.py +1 -0
- pytextad-0.1.0.dist-info/METADATA +108 -0
- pytextad-0.1.0.dist-info/RECORD +16 -0
- pytextad-0.1.0.dist-info/WHEEL +5 -0
- pytextad-0.1.0.dist-info/licenses/LICENSE +24 -0
- pytextad-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +36 -0
- pytextad-0.1.0.dist-info/top_level.txt +1 -0
pytextad/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""PyTextAD: a unified library for text anomaly detection, in the style of PyOD."""
|
|
2
|
+
from .version import __version__
|
|
3
|
+
from .models.cvdd import CVDD
|
|
4
|
+
from .models.date import DATE
|
|
5
|
+
from .models.fate import FATE
|
|
6
|
+
from .models.rsrae import RSRAE
|
|
7
|
+
from .utils.embeddings import TokenEmbedder, mean_pool, words_from_subwords
|
|
8
|
+
|
|
9
|
+
__all__ = ["__version__", "CVDD", "DATE", "FATE", "RSRAE", "TokenEmbedder", "mean_pool", "words_from_subwords"]
|
pytextad/models/base.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Common interface for all detectors, modelled on PyOD's BaseDetector.
|
|
2
|
+
|
|
3
|
+
Conventions (identical to PyOD):
|
|
4
|
+
* fit(X, y=None) returns self and sets ``decision_scores_`` (scores of the training data),
|
|
5
|
+
``threshold_`` and ``labels_``.
|
|
6
|
+
* decision_function(X) returns one score per sample; higher = more anomalous.
|
|
7
|
+
* predict(X) returns 0/1 using ``threshold_`` (the (1 - contamination) quantile of
|
|
8
|
+
the training scores).
|
|
9
|
+
Detectors that can score tokens additionally implement ``token_scores``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import abc
|
|
13
|
+
import random
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import torch
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BaseTextDetector(abc.ABC):
|
|
20
|
+
|
|
21
|
+
def __init__(self, contamination=0.1, random_state=0, device=None, verbose=False):
|
|
22
|
+
if not 0.0 < contamination <= 0.5:
|
|
23
|
+
raise ValueError("contamination must be in (0, 0.5]")
|
|
24
|
+
self.contamination = contamination
|
|
25
|
+
self.random_state = random_state
|
|
26
|
+
self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
|
27
|
+
self.verbose = verbose
|
|
28
|
+
|
|
29
|
+
# ------------------------------------------------------------------ to implement
|
|
30
|
+
@abc.abstractmethod
|
|
31
|
+
def fit(self, X, y=None):
|
|
32
|
+
"""Fit the detector on (mostly) normal data and set ``decision_scores_``,
|
|
33
|
+
``threshold_`` and ``labels_``. ``y`` is ignored except by semi-supervised
|
|
34
|
+
detectors (FATE). Returns ``self``."""
|
|
35
|
+
|
|
36
|
+
@abc.abstractmethod
|
|
37
|
+
def decision_function(self, X):
|
|
38
|
+
"""Anomaly score of every sample in ``X``; higher means more anomalous."""
|
|
39
|
+
|
|
40
|
+
# ------------------------------------------------------------------ shared
|
|
41
|
+
def _set_seed(self):
|
|
42
|
+
if self.random_state is None:
|
|
43
|
+
return
|
|
44
|
+
random.seed(self.random_state)
|
|
45
|
+
np.random.seed(self.random_state)
|
|
46
|
+
torch.manual_seed(self.random_state)
|
|
47
|
+
if torch.cuda.is_available():
|
|
48
|
+
torch.cuda.manual_seed_all(self.random_state)
|
|
49
|
+
|
|
50
|
+
def _process_decision_scores(self, scores):
|
|
51
|
+
self.decision_scores_ = np.asarray(scores, dtype=float)
|
|
52
|
+
self.threshold_ = float(np.percentile(self.decision_scores_, 100 * (1 - self.contamination)))
|
|
53
|
+
self.labels_ = (self.decision_scores_ > self.threshold_).astype(int)
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def predict(self, X):
|
|
57
|
+
"""Binary labels (1 = anomaly) using ``threshold_`` from the training scores."""
|
|
58
|
+
self._check_fitted()
|
|
59
|
+
return (self.decision_function(X) > self.threshold_).astype(int)
|
|
60
|
+
|
|
61
|
+
def fit_predict(self, X, y=None):
|
|
62
|
+
"""Fit on ``X`` and return the labels of the training data (``labels_``)."""
|
|
63
|
+
return self.fit(X, y).labels_
|
|
64
|
+
|
|
65
|
+
def _check_fitted(self):
|
|
66
|
+
if not hasattr(self, "decision_scores_"):
|
|
67
|
+
raise RuntimeError(f"{type(self).__name__} is not fitted yet; call fit() first.")
|
|
68
|
+
|
|
69
|
+
def _log(self, msg):
|
|
70
|
+
if self.verbose:
|
|
71
|
+
print(f"[{type(self).__name__}] {msg}")
|
pytextad/models/cvdd.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""CVDD: Context Vector Data Description (Ruff et al., ACL 2019).
|
|
2
|
+
|
|
3
|
+
Re-implementation for modern PyTorch. The model and training logic follow the
|
|
4
|
+
official code, https://github.com/lukasruff/CVDD-PyTorch (MIT License,
|
|
5
|
+
Copyright (c) 2019 lukasruff); see THIRD_PARTY_NOTICES.
|
|
6
|
+
|
|
7
|
+
Input: a list of frozen token-embedding matrices, one [n_tokens, dim] array per
|
|
8
|
+
document (GloVe/fastText vectors, or hidden states of a frozen PLM, e.g. from
|
|
9
|
+
``pytextad.embeddings.TokenEmbedder``). Only the self-attention layer and the context
|
|
10
|
+
vectors are trained, exactly as in the official code.
|
|
11
|
+
|
|
12
|
+
Faithful to the official code
|
|
13
|
+
* self-attention A = softmax_over_tokens(W2 tanh(W1 H)), r heads, no biases
|
|
14
|
+
* M = A H; cosine distance d_k = 0.5 (1 - cos(M_k, c_k))
|
|
15
|
+
* context vectors initialised by k-means on L2-normalised mean token embeddings
|
|
16
|
+
of the training set, then L2-normalised
|
|
17
|
+
* loss = mean_n sum_k softmax_k(-alpha d) d_k + lambda_p * mean((C C^T - I)^2)
|
|
18
|
+
* temperature alpha annealed at 5 equidistant milestones
|
|
19
|
+
(soft / linear / logarithmic / hard, same values as the official code)
|
|
20
|
+
* Adam with weight decay, gradient-norm clipping at 0.5, MultiStepLR(gamma=0.1)
|
|
21
|
+
stepped at the start of each epoch as in the official trainer (so with
|
|
22
|
+
PyTorch >= 1.1 the LR drop happens one epoch before the nominal milestone)
|
|
23
|
+
* anomaly score = mean_k d_k ("context_dist_mean", the official default)
|
|
24
|
+
* defaults = the settings in the official README for Reuters / 20 Newsgroups
|
|
25
|
+
(3 heads, attention size 150, lambda_p 1, logarithmic, 100 epochs, lr 0.01,
|
|
26
|
+
lr milestone 40, batch 64, weight decay 5e-7)
|
|
27
|
+
|
|
28
|
+
Deliberate deviations
|
|
29
|
+
* Padding is masked out of the attention softmax. The official code pads with index
|
|
30
|
+
0 and does not mask. With static word vectors (GloVe, fastText) the pad vector is
|
|
31
|
+
zero, which only rescales M, so the cosine distances, the loss and the training
|
|
32
|
+
are identical (verified numerically with variable-length documents). With PLM
|
|
33
|
+
hidden states the official "bert" option feeds non-zero pad vectors into M, which
|
|
34
|
+
makes a document's score depend on the length of the others in its batch; we do
|
|
35
|
+
not reproduce that.
|
|
36
|
+
* Mini-batches are drawn uniformly at random and every document is scored. The
|
|
37
|
+
official loaders group documents by length (BucketBatchSampler) with
|
|
38
|
+
drop_last=True for both training and testing, so up to batch_size - 1 test
|
|
39
|
+
documents are never scored in the official evaluation.
|
|
40
|
+
* The official "context_best" score is NOT provided: it selects, per run, the
|
|
41
|
+
head with the highest AUROC on the labelled test set, which uses test labels.
|
|
42
|
+
``head_scores()`` returns per-head distances if you need them.
|
|
43
|
+
|
|
44
|
+
Extension (not in the CVDD paper)
|
|
45
|
+
* token_scores(): min over heads of the cosine distance between each token vector
|
|
46
|
+
and the context vectors. Comparable across documents. Use with care: it is our
|
|
47
|
+
definition, not part of CVDD.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
import warnings
|
|
51
|
+
|
|
52
|
+
import numpy as np
|
|
53
|
+
import torch
|
|
54
|
+
import torch.nn as nn
|
|
55
|
+
import torch.nn.functional as F
|
|
56
|
+
from sklearn.cluster import KMeans
|
|
57
|
+
|
|
58
|
+
from .base import BaseTextDetector
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _alpha_schedule(name, n_epochs):
|
|
62
|
+
milestones = list(np.arange(1, 6) * int(n_epochs / 5))
|
|
63
|
+
if name == "soft":
|
|
64
|
+
alphas = [0.0] * 5
|
|
65
|
+
elif name == "linear":
|
|
66
|
+
alphas = list(np.linspace(0.2, 1, 5))
|
|
67
|
+
elif name == "logarithmic":
|
|
68
|
+
alphas = list(np.logspace(-4, 0, 5))
|
|
69
|
+
elif name == "hard":
|
|
70
|
+
alphas = [100.0] * 4 # official list has 4 entries
|
|
71
|
+
else:
|
|
72
|
+
raise ValueError(f"unknown alpha_scheduler {name}")
|
|
73
|
+
return milestones, alphas
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _CVDDNet(nn.Module):
|
|
77
|
+
def __init__(self, dim, attention_size, n_heads):
|
|
78
|
+
super().__init__()
|
|
79
|
+
self.W1 = nn.Linear(dim, attention_size, bias=False)
|
|
80
|
+
self.W2 = nn.Linear(attention_size, n_heads, bias=False)
|
|
81
|
+
self.c = nn.Parameter((torch.rand(n_heads, dim) - 0.5) * 2)
|
|
82
|
+
self.alpha = 0.0
|
|
83
|
+
|
|
84
|
+
def attend(self, H, mask):
|
|
85
|
+
# H [B, L, d], mask [B, L] (True = real token)
|
|
86
|
+
logits = self.W2(torch.tanh(self.W1(H))) # [B, L, r]
|
|
87
|
+
logits = logits.masked_fill(~mask.unsqueeze(-1), float("-inf"))
|
|
88
|
+
A = F.softmax(logits, dim=1).transpose(1, 2) # [B, r, L]
|
|
89
|
+
return A @ H, A # M [B, r, d]
|
|
90
|
+
|
|
91
|
+
def forward(self, H, mask):
|
|
92
|
+
M, A = self.attend(H, mask)
|
|
93
|
+
d = 0.5 * (1 - F.cosine_similarity(M, self.c.unsqueeze(0), dim=2)) # [B, r]
|
|
94
|
+
w = F.softmax(-self.alpha * d, dim=1)
|
|
95
|
+
return d, w, A
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class CVDD(BaseTextDetector):
|
|
99
|
+
|
|
100
|
+
def __init__(self, n_heads=3, attention_size=150, lambda_p=1.0,
|
|
101
|
+
alpha_scheduler="logarithmic", n_epochs=100, lr=0.01, lr_milestones=(40,),
|
|
102
|
+
batch_size=64, weight_decay=0.5e-6, contamination=0.1, random_state=0,
|
|
103
|
+
device=None, verbose=False):
|
|
104
|
+
super().__init__(contamination, random_state, device, verbose)
|
|
105
|
+
self.n_heads = n_heads
|
|
106
|
+
self.attention_size = attention_size
|
|
107
|
+
self.lambda_p = lambda_p
|
|
108
|
+
self.alpha_scheduler = alpha_scheduler
|
|
109
|
+
self.n_epochs = n_epochs
|
|
110
|
+
self.lr = lr
|
|
111
|
+
self.lr_milestones = tuple(lr_milestones)
|
|
112
|
+
self.batch_size = batch_size
|
|
113
|
+
self.weight_decay = weight_decay
|
|
114
|
+
|
|
115
|
+
# ------------------------------------------------------------------ batching
|
|
116
|
+
@staticmethod
|
|
117
|
+
def _check(X):
|
|
118
|
+
X = [np.asarray(x, dtype=np.float32) for x in X]
|
|
119
|
+
if any(x.ndim != 2 or len(x) == 0 for x in X):
|
|
120
|
+
raise ValueError("X must be a list of non-empty [n_tokens, dim] arrays")
|
|
121
|
+
if len({x.shape[1] for x in X}) != 1:
|
|
122
|
+
raise ValueError("all token embeddings must have the same dimension")
|
|
123
|
+
return X
|
|
124
|
+
|
|
125
|
+
def _batch(self, X, idx):
|
|
126
|
+
L = max(len(X[i]) for i in idx)
|
|
127
|
+
H = np.zeros((len(idx), L, X[idx[0]].shape[1]), dtype=np.float32)
|
|
128
|
+
mask = np.zeros((len(idx), L), dtype=bool)
|
|
129
|
+
for j, i in enumerate(idx):
|
|
130
|
+
H[j, :len(X[i])] = X[i]
|
|
131
|
+
mask[j, :len(X[i])] = True
|
|
132
|
+
return torch.from_numpy(H).to(self.device), torch.from_numpy(mask).to(self.device)
|
|
133
|
+
|
|
134
|
+
def _batches(self, n, shuffle):
|
|
135
|
+
order = np.random.permutation(n) if shuffle else np.arange(n)
|
|
136
|
+
for s in range(0, n, self.batch_size):
|
|
137
|
+
yield order[s:s + self.batch_size]
|
|
138
|
+
|
|
139
|
+
# ------------------------------------------------------------------ training
|
|
140
|
+
def fit(self, X, y=None):
|
|
141
|
+
self._set_seed()
|
|
142
|
+
X = self._check(X)
|
|
143
|
+
dim = X[0].shape[1]
|
|
144
|
+
self.net_ = _CVDDNet(dim, self.attention_size, self.n_heads).to(self.device)
|
|
145
|
+
|
|
146
|
+
# context vector initialisation (official initialize_context_vectors)
|
|
147
|
+
means = np.stack([x.mean(0) for x in X])
|
|
148
|
+
means = means / np.clip(np.linalg.norm(means, axis=1, keepdims=True), 1e-8, None)
|
|
149
|
+
km = KMeans(n_clusters=self.n_heads, n_init=10, random_state=self.random_state).fit(means)
|
|
150
|
+
centers = km.cluster_centers_ / np.linalg.norm(km.cluster_centers_, axis=1, keepdims=True)
|
|
151
|
+
self.net_.c.data = torch.from_numpy(centers.astype(np.float32)).to(self.device)
|
|
152
|
+
|
|
153
|
+
opt = torch.optim.Adam(self.net_.parameters(), lr=self.lr, weight_decay=self.weight_decay)
|
|
154
|
+
sched = torch.optim.lr_scheduler.MultiStepLR(opt, milestones=list(self.lr_milestones), gamma=0.1)
|
|
155
|
+
milestones, alphas = _alpha_schedule(self.alpha_scheduler, self.n_epochs)
|
|
156
|
+
alpha_i = 0
|
|
157
|
+
I = torch.eye(self.n_heads, device=self.device)
|
|
158
|
+
|
|
159
|
+
self.net_.alpha = 0.0
|
|
160
|
+
self.history_ = []
|
|
161
|
+
for epoch in range(self.n_epochs):
|
|
162
|
+
with warnings.catch_warnings(): # official order: step at the START of each epoch
|
|
163
|
+
warnings.simplefilter("ignore", UserWarning)
|
|
164
|
+
sched.step()
|
|
165
|
+
if epoch in milestones and alpha_i < len(alphas): # official: one step per epoch
|
|
166
|
+
self.net_.alpha = float(alphas[alpha_i])
|
|
167
|
+
alpha_i += 1
|
|
168
|
+
self.net_.train()
|
|
169
|
+
tot, nb = 0.0, 0
|
|
170
|
+
for idx in self._batches(len(X), shuffle=True):
|
|
171
|
+
H, mask = self._batch(X, idx)
|
|
172
|
+
d, w, _ = self.net_(H, mask)
|
|
173
|
+
P = torch.mean((self.net_.c @ self.net_.c.t() - I) ** 2)
|
|
174
|
+
loss = torch.mean(torch.sum(w * d, dim=1)) + self.lambda_p * P
|
|
175
|
+
opt.zero_grad()
|
|
176
|
+
loss.backward()
|
|
177
|
+
torch.nn.utils.clip_grad_norm_(self.net_.parameters(), 0.5) # as in the official trainer
|
|
178
|
+
opt.step()
|
|
179
|
+
tot += loss.item()
|
|
180
|
+
nb += 1
|
|
181
|
+
self.history_.append(tot / nb)
|
|
182
|
+
self._log(f"epoch {epoch + 1}/{self.n_epochs} loss {tot / nb:.6f} alpha {self.net_.alpha:g}")
|
|
183
|
+
|
|
184
|
+
return self._process_decision_scores(self.decision_function(X))
|
|
185
|
+
|
|
186
|
+
# ------------------------------------------------------------------ inference
|
|
187
|
+
@torch.no_grad()
|
|
188
|
+
def _forward_all(self, X):
|
|
189
|
+
X = self._check(X)
|
|
190
|
+
self.net_.eval()
|
|
191
|
+
D, A_all = [], []
|
|
192
|
+
for idx in self._batches(len(X), shuffle=False):
|
|
193
|
+
H, mask = self._batch(X, idx)
|
|
194
|
+
d, _, A = self.net_(H, mask)
|
|
195
|
+
D.append(d.cpu().numpy())
|
|
196
|
+
A = A.cpu().numpy()
|
|
197
|
+
A_all += [A[j, :, :len(X[i])] for j, i in enumerate(idx)]
|
|
198
|
+
return np.concatenate(D), A_all
|
|
199
|
+
|
|
200
|
+
def head_scores(self, X):
|
|
201
|
+
"""Per-head cosine distances, shape [n_docs, n_heads]."""
|
|
202
|
+
return self._forward_all(X)[0]
|
|
203
|
+
|
|
204
|
+
def decision_function(self, X):
|
|
205
|
+
"""Official 'context_dist_mean' score: mean cosine distance over heads."""
|
|
206
|
+
return self.head_scores(X).mean(1)
|
|
207
|
+
|
|
208
|
+
def attention(self, X):
|
|
209
|
+
"""Attention weights per document, each [n_heads, n_tokens] (for inspection)."""
|
|
210
|
+
return self._forward_all(X)[1]
|
|
211
|
+
|
|
212
|
+
@torch.no_grad()
|
|
213
|
+
def token_scores(self, X):
|
|
214
|
+
"""EXTENSION (not in the paper): per-token min_k 0.5 (1 - cos(h_t, c_k))."""
|
|
215
|
+
X = self._check(X)
|
|
216
|
+
C = F.normalize(self.net_.c.detach(), dim=1).cpu().numpy()
|
|
217
|
+
out = []
|
|
218
|
+
for x in X:
|
|
219
|
+
xn = x / np.clip(np.linalg.norm(x, axis=1, keepdims=True), 1e-8, None)
|
|
220
|
+
out.append((0.5 * (1 - xn @ C.T)).min(1))
|
|
221
|
+
return out
|
pytextad/models/date.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""DATE: Detecting Anomalies in Text via Self-Supervision of Transformers
|
|
2
|
+
(Manolache, Brad & Burceanu, NAACL 2021).
|
|
3
|
+
|
|
4
|
+
Re-implementation for modern PyTorch / transformers (4.x and 5.x). Logic follows
|
|
5
|
+
the official code, https://github.com/bit-ml/date (experiments/train_ag.py defaults
|
|
6
|
+
and the modified simpletransformers fork it ships, Apache License 2.0).
|
|
7
|
+
|
|
8
|
+
Faithful to the official code
|
|
9
|
+
* discriminator: ELECTRA encoder trained from scratch on inlier text
|
|
10
|
+
(embedding 128, hidden 256, 4 layers, 4 heads, FFN 1024, dropout 0.5, gelu),
|
|
11
|
+
RTD head dense-gelu-linear, RMD head on [CLS] linear-relu-linear
|
|
12
|
+
* pretext task: one of K=50 fixed masks (50% of the 128 positions, first position
|
|
13
|
+
always masked) chosen uniformly per sequence; masked positions get uniformly
|
|
14
|
+
random token ids in [5, vocab-1) ("random_generator=1", the default)
|
|
15
|
+
* loss = 50 * BCE(RTD) + 100 * CE(RMD); RTD labels are 0 where the random id
|
|
16
|
+
happens to equal the original token
|
|
17
|
+
* no attention mask is given to the encoder, and the RTD loss is taken over all
|
|
18
|
+
positions including [CLS] and padding, exactly as in the official training loop
|
|
19
|
+
(use_attention_mask=True changes this)
|
|
20
|
+
* AdamW (amsgrad, eps 1e-8), lr 1e-5, weight decay 0.1, and 0.01 (AdamW's default,
|
|
21
|
+
because the official parameter group omits the key) for bias/LayerNorm,
|
|
22
|
+
gradient-norm clipping 1.0, batch 16, 20 epochs; the official "plateau"
|
|
23
|
+
scheduler has patience 1e5 and therefore never changes the LR
|
|
24
|
+
* sliding windows of 128 tokens with stride int(0.8 * 130) = 104 for training and
|
|
25
|
+
testing; tokenizer bert-base-uncased
|
|
26
|
+
* test: no masking; window score = mean over positions 1..128 that are not padding
|
|
27
|
+
(so [SEP] is included unless it falls on position 129) of P(original token);
|
|
28
|
+
document score = mean over its windows. The official AUROC is computed on this
|
|
29
|
+
"PL_RTD" score with inliers as the positive class; decision_function returns
|
|
30
|
+
1 - score so that higher = more anomalous (identical AUROC).
|
|
31
|
+
|
|
32
|
+
Removed relative to the official code (no effect with the default settings)
|
|
33
|
+
* The ELECTRA generator: with random_generator=1 its samples are overwritten by
|
|
34
|
+
random ids and its MLM loss is never added to the objective.
|
|
35
|
+
|
|
36
|
+
Not reproduced
|
|
37
|
+
* The official code evaluates on the test set every 500 steps while training.
|
|
38
|
+
Here you get the model after the last epoch.
|
|
39
|
+
|
|
40
|
+
Extension (not in the paper)
|
|
41
|
+
* token_scores(): per-subword P(replaced), max over overlapping windows, aggregated
|
|
42
|
+
to words with max.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
import pickle
|
|
46
|
+
from typing import List, Sequence, Union
|
|
47
|
+
|
|
48
|
+
import numpy as np
|
|
49
|
+
import torch
|
|
50
|
+
import torch.nn as nn
|
|
51
|
+
import torch.nn.functional as F
|
|
52
|
+
from transformers import ElectraConfig, ElectraModel
|
|
53
|
+
|
|
54
|
+
from .base import BaseTextDetector
|
|
55
|
+
from ..utils.embeddings import words_from_subwords
|
|
56
|
+
|
|
57
|
+
Text = Union[str, Sequence[str]]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _DATENet(nn.Module):
|
|
61
|
+
def __init__(self, vocab_size, n_masks, hidden=256, layers=4, heads=4, intermediate=1024,
|
|
62
|
+
emb=128, dropout=0.5):
|
|
63
|
+
super().__init__()
|
|
64
|
+
cfg = ElectraConfig(vocab_size=vocab_size, embedding_size=emb, hidden_size=hidden,
|
|
65
|
+
num_hidden_layers=layers, num_attention_heads=heads,
|
|
66
|
+
intermediate_size=intermediate, hidden_dropout_prob=dropout,
|
|
67
|
+
attention_probs_dropout_prob=dropout, max_position_embeddings=512,
|
|
68
|
+
hidden_act="gelu")
|
|
69
|
+
self.encoder = ElectraModel(cfg)
|
|
70
|
+
self.rtd_dense = nn.Linear(hidden, hidden) # ElectraDiscriminatorPredictions
|
|
71
|
+
self.rtd_out = nn.Linear(hidden, 1)
|
|
72
|
+
self.rmd_fc1 = nn.Linear(hidden, hidden) # ElectraRMD
|
|
73
|
+
self.rmd_fc2 = nn.Linear(hidden, n_masks)
|
|
74
|
+
|
|
75
|
+
def forward(self, input_ids, attention_mask=None):
|
|
76
|
+
h = self.encoder(input_ids=input_ids, attention_mask=attention_mask).last_hidden_state
|
|
77
|
+
rtd = self.rtd_out(F.gelu(self.rtd_dense(h))).squeeze(-1) # [B, L]
|
|
78
|
+
rmd = self.rmd_fc2(F.relu(self.rmd_fc1(h[:, 0]))) # [B, K]
|
|
79
|
+
return rtd, rmd
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class DATE(BaseTextDetector):
|
|
83
|
+
|
|
84
|
+
def __init__(self, tokenizer="bert-base-uncased", max_len=128, stride=0.8,
|
|
85
|
+
n_masks=50, mask_ratio=0.5, masks=None,
|
|
86
|
+
hidden=256, layers=4, heads=4, intermediate=1024, emb=128, dropout=0.5,
|
|
87
|
+
rtd_weight=50.0, rmd_weight=100.0, lr=1e-5, weight_decay=0.1,
|
|
88
|
+
max_grad_norm=1.0, n_epochs=20, batch_size=16, use_attention_mask=False,
|
|
89
|
+
contamination=0.1, random_state=0, device=None, verbose=False):
|
|
90
|
+
"""
|
|
91
|
+
tokenizer : HF name or path, a vocab.txt path, or a fast tokenizer object.
|
|
92
|
+
masks : None -> K random masks from random_state, or the path of the official
|
|
93
|
+
experiments/pseudo_labels128_p50.pkl to use the exact official set.
|
|
94
|
+
"""
|
|
95
|
+
super().__init__(contamination, random_state, device, verbose)
|
|
96
|
+
self.tokenizer = tokenizer
|
|
97
|
+
self.max_len = max_len
|
|
98
|
+
self.stride = stride
|
|
99
|
+
self.n_masks = n_masks
|
|
100
|
+
self.mask_ratio = mask_ratio
|
|
101
|
+
self.masks = masks
|
|
102
|
+
self.hidden, self.layers, self.heads = hidden, layers, heads
|
|
103
|
+
self.intermediate, self.emb, self.dropout = intermediate, emb, dropout
|
|
104
|
+
self.rtd_weight, self.rmd_weight = rtd_weight, rmd_weight
|
|
105
|
+
self.lr, self.weight_decay, self.max_grad_norm = lr, weight_decay, max_grad_norm
|
|
106
|
+
self.n_epochs, self.batch_size = n_epochs, batch_size
|
|
107
|
+
self.use_attention_mask = use_attention_mask
|
|
108
|
+
|
|
109
|
+
# ------------------------------------------------------------------ setup
|
|
110
|
+
def _load_tokenizer(self):
|
|
111
|
+
from transformers import AutoTokenizer, BertTokenizerFast
|
|
112
|
+
tok = self.tokenizer
|
|
113
|
+
if isinstance(tok, str):
|
|
114
|
+
if tok.endswith(".txt"):
|
|
115
|
+
vocab = {w.rstrip("\n"): i for i, w in enumerate(open(tok, encoding="utf-8"))}
|
|
116
|
+
try: # transformers >= 5
|
|
117
|
+
tok = BertTokenizerFast(vocab=vocab, do_lower_case=True)
|
|
118
|
+
except TypeError:
|
|
119
|
+
tok = None
|
|
120
|
+
if tok is None or len(tok) < len(vocab): # transformers 4.x
|
|
121
|
+
tok = BertTokenizerFast(vocab_file=self.tokenizer, do_lower_case=True)
|
|
122
|
+
else:
|
|
123
|
+
tok = AutoTokenizer.from_pretrained(tok, use_fast=True)
|
|
124
|
+
if not getattr(tok, "is_fast", False):
|
|
125
|
+
raise ValueError("A fast tokenizer is required (for word alignment).")
|
|
126
|
+
self.tok_ = tok
|
|
127
|
+
|
|
128
|
+
def _build_masks(self):
|
|
129
|
+
L = self.max_len
|
|
130
|
+
if isinstance(self.masks, str):
|
|
131
|
+
raw = sorted(pickle.load(open(self.masks, "rb")), key=lambda m: m["label"])
|
|
132
|
+
M = np.array([m["mask"] for m in raw], dtype=bool)
|
|
133
|
+
if M.shape[1] != L:
|
|
134
|
+
raise ValueError(f"mask length {M.shape[1]} != max_len {L}")
|
|
135
|
+
else:
|
|
136
|
+
rng = np.random.RandomState(self.random_state)
|
|
137
|
+
k = int(self.mask_ratio * L)
|
|
138
|
+
M = np.zeros((self.n_masks, L), dtype=bool)
|
|
139
|
+
for i in range(self.n_masks):
|
|
140
|
+
M[i, rng.choice(L, k, replace=False)] = True
|
|
141
|
+
M = M.copy()
|
|
142
|
+
M[:, 0] = True # official: first content position always masked
|
|
143
|
+
full = np.zeros((M.shape[0], L + 2), dtype=bool)
|
|
144
|
+
full[:, 1:L + 1] = M
|
|
145
|
+
self.masks_ = torch.from_numpy(full)
|
|
146
|
+
self.n_masks_ = M.shape[0]
|
|
147
|
+
|
|
148
|
+
# ------------------------------------------------------------------ windowing
|
|
149
|
+
def _encode(self, texts: List[Text]):
|
|
150
|
+
out = []
|
|
151
|
+
for t in texts:
|
|
152
|
+
split = not isinstance(t, str)
|
|
153
|
+
enc = self.tok_(list(t) if split else t, is_split_into_words=split,
|
|
154
|
+
add_special_tokens=False, truncation=False)
|
|
155
|
+
out.append((enc["input_ids"], enc.word_ids()))
|
|
156
|
+
return out
|
|
157
|
+
|
|
158
|
+
def _windows(self, ids):
|
|
159
|
+
"""encode_sliding_window_custom: windows of max_len, step int((max_len + 2) * stride)."""
|
|
160
|
+
L = self.max_len
|
|
161
|
+
if len(ids) <= L:
|
|
162
|
+
return [(0, ids)]
|
|
163
|
+
step = max(1, int((L + 2) * self.stride))
|
|
164
|
+
return [(s, ids[s:s + L]) for s in range(0, len(ids), step)]
|
|
165
|
+
|
|
166
|
+
def _pack(self, chunks):
|
|
167
|
+
cls, sep, pad = self.tok_.cls_token_id, self.tok_.sep_token_id, self.tok_.pad_token_id
|
|
168
|
+
X = torch.full((len(chunks), self.max_len + 2), pad, dtype=torch.long)
|
|
169
|
+
for i, c in enumerate(chunks):
|
|
170
|
+
seq = [cls] + list(c) + [sep]
|
|
171
|
+
X[i, :len(seq)] = torch.tensor(seq)
|
|
172
|
+
return X, X != pad
|
|
173
|
+
|
|
174
|
+
# ------------------------------------------------------------------ training
|
|
175
|
+
def _corrupt(self, X, nonpad):
|
|
176
|
+
labels = torch.randint(0, self.n_masks_, (X.shape[0],))
|
|
177
|
+
m = self.masks_[labels] & nonpad # padding is never replaced
|
|
178
|
+
rand = torch.randint(5, self.vocab_size_ - 1, X.shape)
|
|
179
|
+
Xc = X.clone()
|
|
180
|
+
Xc[m] = rand[m]
|
|
181
|
+
return Xc, (m & (Xc != X)).float(), labels
|
|
182
|
+
|
|
183
|
+
def _loss(self, Xc, nonpad, rtd_y, rmd_y):
|
|
184
|
+
am = nonpad.long() if self.use_attention_mask else None
|
|
185
|
+
rtd, rmd = self.net_(Xc, am)
|
|
186
|
+
if self.use_attention_mask:
|
|
187
|
+
l_rtd = F.binary_cross_entropy_with_logits(rtd[nonpad], rtd_y[nonpad])
|
|
188
|
+
else: # official: all positions
|
|
189
|
+
l_rtd = F.binary_cross_entropy_with_logits(rtd, rtd_y)
|
|
190
|
+
l_rmd = F.cross_entropy(rmd, rmd_y)
|
|
191
|
+
return self.rtd_weight * l_rtd + self.rmd_weight * l_rmd
|
|
192
|
+
|
|
193
|
+
def _make_optimizer(self):
|
|
194
|
+
no_decay = ("bias", "LayerNorm.weight")
|
|
195
|
+
named = list(self.net_.named_parameters())
|
|
196
|
+
return torch.optim.AdamW(
|
|
197
|
+
[{"params": [p for n, p in named if not any(k in n for k in no_decay)], "weight_decay": self.weight_decay},
|
|
198
|
+
# the official group for bias/LayerNorm has no "weight_decay" key, so it gets
|
|
199
|
+
# torch.optim.AdamW's default 0.01 (not 0)
|
|
200
|
+
{"params": [p for n, p in named if any(k in n for k in no_decay)], "weight_decay": 0.01}],
|
|
201
|
+
lr=self.lr, eps=1e-8, amsgrad=True)
|
|
202
|
+
|
|
203
|
+
def _train_step(self, Xc, nonpad, rtd_y, rmd_y, opt):
|
|
204
|
+
loss = self._loss(Xc, nonpad, rtd_y, rmd_y)
|
|
205
|
+
opt.zero_grad()
|
|
206
|
+
loss.backward()
|
|
207
|
+
torch.nn.utils.clip_grad_norm_(self.net_.parameters(), self.max_grad_norm)
|
|
208
|
+
opt.step()
|
|
209
|
+
return loss.item()
|
|
210
|
+
|
|
211
|
+
def fit(self, X: List[Text], y=None):
|
|
212
|
+
self._set_seed()
|
|
213
|
+
self._load_tokenizer()
|
|
214
|
+
self.vocab_size_ = len(self.tok_)
|
|
215
|
+
self._build_masks()
|
|
216
|
+
windows = [w for ids, _ in self._encode(X) for _, w in self._windows(ids) if len(w) > 0]
|
|
217
|
+
data, nonpad = self._pack(windows)
|
|
218
|
+
|
|
219
|
+
self.net_ = _DATENet(self.vocab_size_, self.n_masks_, self.hidden, self.layers, self.heads,
|
|
220
|
+
self.intermediate, self.emb, self.dropout).to(self.device)
|
|
221
|
+
opt = self._make_optimizer()
|
|
222
|
+
|
|
223
|
+
n = data.shape[0]
|
|
224
|
+
self.history_ = []
|
|
225
|
+
for ep in range(self.n_epochs):
|
|
226
|
+
self.net_.train()
|
|
227
|
+
perm = torch.randperm(n)
|
|
228
|
+
tot = 0.0
|
|
229
|
+
for s in range(0, n, self.batch_size):
|
|
230
|
+
idx = perm[s:s + self.batch_size]
|
|
231
|
+
Xc, rtd_y, rmd_y = self._corrupt(data[idx], nonpad[idx])
|
|
232
|
+
loss = self._train_step(Xc.to(self.device), nonpad[idx].to(self.device),
|
|
233
|
+
rtd_y.to(self.device), rmd_y.to(self.device), opt)
|
|
234
|
+
tot += loss * len(idx)
|
|
235
|
+
self.history_.append(tot / n)
|
|
236
|
+
self._log(f"epoch {ep + 1}/{self.n_epochs} loss {tot / n:.4f} ({n} windows)")
|
|
237
|
+
return self._process_decision_scores(self.decision_function(X))
|
|
238
|
+
|
|
239
|
+
# ------------------------------------------------------------------ inference
|
|
240
|
+
@torch.no_grad()
|
|
241
|
+
def _score(self, X: List[Text], batch_size=64):
|
|
242
|
+
self.net_.eval()
|
|
243
|
+
enc = self._encode(X)
|
|
244
|
+
jobs = [(d, s, w) for d, (ids, _) in enumerate(enc) for s, w in self._windows(ids) if len(w) > 0]
|
|
245
|
+
sub = [np.full(len(ids), -np.inf) for ids, _ in enc]
|
|
246
|
+
win = [[] for _ in enc]
|
|
247
|
+
L2 = self.max_len + 2
|
|
248
|
+
for b in range(0, len(jobs), batch_size):
|
|
249
|
+
part = jobs[b:b + batch_size]
|
|
250
|
+
xb, nonpad = self._pack([j[2] for j in part])
|
|
251
|
+
am = nonpad.long().to(self.device) if self.use_attention_mask else None
|
|
252
|
+
rtd, _ = self.net_(xb.to(self.device), am)
|
|
253
|
+
p_rep = torch.sigmoid(rtd).cpu().numpy()
|
|
254
|
+
for (d, s, w), pr, npd in zip(part, p_rep, nonpad.numpy()):
|
|
255
|
+
keep = npd.copy()
|
|
256
|
+
keep[0] = False
|
|
257
|
+
keep[L2 - 1] = False # official skips positions 0 and 129
|
|
258
|
+
win[d].append(1.0 - pr[keep].mean()) # PL_RTD = mean P(original)
|
|
259
|
+
sub[d][s:s + len(w)] = np.maximum(sub[d][s:s + len(w)], pr[1:1 + len(w)])
|
|
260
|
+
return win, sub, [e[1] for e in enc]
|
|
261
|
+
|
|
262
|
+
def decision_function(self, X: List[Text]):
|
|
263
|
+
win, _, _ = self._score(X)
|
|
264
|
+
return np.array([1.0 - np.mean(w) if w else np.nan for w in win])
|
|
265
|
+
|
|
266
|
+
def token_scores(self, X: List[Text], agg="max"):
|
|
267
|
+
"""EXTENSION: per-word P(replaced). For word lists the output has one score per word."""
|
|
268
|
+
_, sub, wids = self._score(X)
|
|
269
|
+
return [words_from_subwords(p, wid, len(x) if not isinstance(x, str) else None, agg)
|
|
270
|
+
for x, p, wid in zip(X, sub, wids)]
|
pytextad/models/fate.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""FATE: Few-shot Anomaly detection in TExt (Das, Ajay, Saha & Bhuyan, ICONIP 2023).
|
|
2
|
+
|
|
3
|
+
Re-implementation of the official code, https://github.com/arav1ndajay/fate
|
|
4
|
+
(src/main.py, deviation_loss.py, balanced_sampler.py). The repository has no
|
|
5
|
+
licence file, so no code is copied; the logic is re-written and was checked
|
|
6
|
+
numerically against it (see tests/).
|
|
7
|
+
|
|
8
|
+
Faithful to the official code
|
|
9
|
+
* encoder: a SentenceTransformer, fine-tuned end to end; default
|
|
10
|
+
sentence-transformers/all-MiniLM-L6-v2 (the one hard-coded in main.py);
|
|
11
|
+
h = its token embeddings (last transformer layer, before pooling)
|
|
12
|
+
* inputs padded to max_length=128 and the attention softmax runs over all 128
|
|
13
|
+
positions, padding included (mask_padding=True changes this)
|
|
14
|
+
* A = softmax_over_tokens(tanh(h W1) W2), attention size 150, 5 heads, no biases;
|
|
15
|
+
S = A h flattened (heads x dim); score = mean of the top 10 % of |S|
|
|
16
|
+
(the code takes absolute values; the paper's formula does not)
|
|
17
|
+
* deviation loss: a new reference sample of 5000 N(0, 1) draws at EVERY batch;
|
|
18
|
+
Z = (score - mean(ref)) / std(ref); loss = mean((1-y)|Z| + y max(0, 5 - Z))
|
|
19
|
+
* regulariser mean((A A^T - I)^2) (a mean, not the paper's Frobenius sum), weight 1
|
|
20
|
+
* balanced batches: 8 inliers + 8 labelled anomalies, each drawn from its own
|
|
21
|
+
endlessly re-shuffled list; steps per epoch = (n_inliers + n_anomalies) // 16
|
|
22
|
+
* Adam, lr 1e-6, batch 16; epochs in main.py: 4 for AG News, 50 for 20 Newsgroups,
|
|
23
|
+
40 for Reuters, 80 whenever there are < 500 training inliers (default here: 4)
|
|
24
|
+
* test score = the network output (no Z-transform), higher = more anomalous
|
|
25
|
+
|
|
26
|
+
Not reproduced
|
|
27
|
+
* main.py evaluates on the test set every 600 steps during training; here you get
|
|
28
|
+
the model after the last epoch.
|
|
29
|
+
|
|
30
|
+
Unsupervised use (y=None or no 1 in y)
|
|
31
|
+
The official sampler cannot run without labelled anomalies. We then fill whole
|
|
32
|
+
batches with inliers and train only the |Z| term: this is the "FATE*" variant of
|
|
33
|
+
NLP-ADBench, not the method of the paper, and should be reported as such.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from typing import List, Optional
|
|
37
|
+
|
|
38
|
+
import numpy as np
|
|
39
|
+
import torch
|
|
40
|
+
import torch.nn as nn
|
|
41
|
+
import torch.nn.functional as F
|
|
42
|
+
|
|
43
|
+
from .base import BaseTextDetector
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _FATENet(nn.Module):
|
|
47
|
+
def __init__(self, encoder, attention_size, n_heads, top_k, mask_padding):
|
|
48
|
+
super().__init__()
|
|
49
|
+
self.encoder = encoder
|
|
50
|
+
d = encoder.config.hidden_size
|
|
51
|
+
self.W1 = nn.Linear(d, attention_size, bias=False)
|
|
52
|
+
self.W2 = nn.Linear(attention_size, n_heads, bias=False)
|
|
53
|
+
self.top_k = top_k
|
|
54
|
+
self.mask_padding = mask_padding
|
|
55
|
+
|
|
56
|
+
def forward(self, input_ids, attention_mask):
|
|
57
|
+
h = self.encoder(input_ids=input_ids, attention_mask=attention_mask).last_hidden_state # [B, T, d]
|
|
58
|
+
logits = self.W2(torch.tanh(self.W1(h))) # [B, T, m]
|
|
59
|
+
if self.mask_padding:
|
|
60
|
+
logits = logits.masked_fill(attention_mask.unsqueeze(-1) == 0, float("-inf"))
|
|
61
|
+
A = F.softmax(logits, dim=1).transpose(1, 2) # [B, m, T]
|
|
62
|
+
S = (A @ h).flatten(1) # [B, m*d]
|
|
63
|
+
k = max(int(S.size(1) * self.top_k), 1)
|
|
64
|
+
return torch.topk(S.abs(), k, dim=1)[0].mean(1).float(), A
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _endless(idx):
|
|
68
|
+
while True:
|
|
69
|
+
for i in np.random.permutation(idx):
|
|
70
|
+
yield i
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class FATE(BaseTextDetector):
|
|
74
|
+
|
|
75
|
+
def __init__(self, encoder="sentence-transformers/all-MiniLM-L6-v2", max_length=128,
|
|
76
|
+
attention_size=150, n_heads=5, top_k=0.1, margin=5.0, n_ref=5000,
|
|
77
|
+
include_regularization=True, mask_padding=False, lr=1e-6, batch_size=16,
|
|
78
|
+
n_epochs=4, contamination=0.1, random_state=0, device=None, verbose=False):
|
|
79
|
+
"""encoder: Hugging Face id or local path of the (Sentence-)BERT encoder; it is fine-tuned."""
|
|
80
|
+
super().__init__(contamination, random_state, device, verbose)
|
|
81
|
+
self.encoder = encoder
|
|
82
|
+
self.max_length = max_length
|
|
83
|
+
self.attention_size = attention_size
|
|
84
|
+
self.n_heads = n_heads
|
|
85
|
+
self.top_k = top_k
|
|
86
|
+
self.margin = margin
|
|
87
|
+
self.n_ref = n_ref
|
|
88
|
+
self.include_regularization = include_regularization
|
|
89
|
+
self.mask_padding = mask_padding
|
|
90
|
+
self.lr = lr
|
|
91
|
+
self.batch_size = batch_size
|
|
92
|
+
self.n_epochs = n_epochs
|
|
93
|
+
|
|
94
|
+
# ------------------------------------------------------------------ pieces
|
|
95
|
+
def _tok(self, texts):
|
|
96
|
+
pad = "longest" if self.mask_padding else "max_length" # official: pad to 128
|
|
97
|
+
return self.tokenizer_(list(texts), padding=pad, truncation=True, max_length=self.max_length,
|
|
98
|
+
return_tensors="pt")
|
|
99
|
+
|
|
100
|
+
def _build(self):
|
|
101
|
+
from transformers import AutoModel, AutoTokenizer
|
|
102
|
+
self.tokenizer_ = AutoTokenizer.from_pretrained(self.encoder)
|
|
103
|
+
enc = AutoModel.from_pretrained(self.encoder)
|
|
104
|
+
self.net_ = _FATENet(enc, self.attention_size, self.n_heads, self.top_k, self.mask_padding).to(self.device)
|
|
105
|
+
|
|
106
|
+
def _loss(self, input_ids, attention_mask, yb):
|
|
107
|
+
psi, A = self.net_(input_ids, attention_mask)
|
|
108
|
+
ref = torch.normal(mean=0.0, std=torch.full([self.n_ref], 1.0)).to(self.device) # redrawn every batch
|
|
109
|
+
z = (psi - ref.mean()) / ref.std()
|
|
110
|
+
loss = torch.mean((1 - yb) * z.abs() + yb * (self.margin - z).clamp(min=0.0))
|
|
111
|
+
if self.include_regularization:
|
|
112
|
+
I = torch.eye(self.n_heads, device=self.device)
|
|
113
|
+
loss = loss + torch.mean((A @ A.transpose(1, 2) - I) ** 2)
|
|
114
|
+
return loss
|
|
115
|
+
|
|
116
|
+
def _train_steps(self, batches, opt):
|
|
117
|
+
"""batches: iterable of (input_ids, attention_mask, labels) tensors."""
|
|
118
|
+
self.net_.train()
|
|
119
|
+
tot, nb = 0.0, 0
|
|
120
|
+
for ids, am, yb in batches:
|
|
121
|
+
opt.zero_grad()
|
|
122
|
+
loss = self._loss(ids.to(self.device), am.to(self.device), yb.float().to(self.device))
|
|
123
|
+
loss.backward()
|
|
124
|
+
opt.step()
|
|
125
|
+
tot += loss.item()
|
|
126
|
+
nb += 1
|
|
127
|
+
return tot / max(nb, 1)
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------------ API
|
|
130
|
+
def fit(self, X: List[str], y: Optional[np.ndarray] = None):
|
|
131
|
+
"""X: texts. y: 1 = labelled anomaly, 0 = inlier (None = all inliers -> FATE*)."""
|
|
132
|
+
self._set_seed()
|
|
133
|
+
X = list(X)
|
|
134
|
+
y = np.zeros(len(X), dtype=int) if y is None else np.asarray(y, dtype=int)
|
|
135
|
+
if len(y) != len(X):
|
|
136
|
+
raise ValueError("X and y have different lengths")
|
|
137
|
+
inl, out = np.where(y == 0)[0], np.where(y == 1)[0]
|
|
138
|
+
self.few_shot_ = len(out) > 0
|
|
139
|
+
if not self.few_shot_:
|
|
140
|
+
self._log("no labelled anomalies: training the unsupervised FATE* variant")
|
|
141
|
+
self._build()
|
|
142
|
+
enc = self._tok(X)
|
|
143
|
+
opt = torch.optim.Adam(self.net_.parameters(), lr=self.lr)
|
|
144
|
+
|
|
145
|
+
n_in = self.batch_size // 2 if self.few_shot_ else self.batch_size
|
|
146
|
+
gen_in, gen_out = _endless(inl), (_endless(out) if self.few_shot_ else None)
|
|
147
|
+
steps = len(X) // self.batch_size
|
|
148
|
+
|
|
149
|
+
def batches():
|
|
150
|
+
for _ in range(steps):
|
|
151
|
+
idx = [next(gen_in) for _ in range(n_in)]
|
|
152
|
+
if self.few_shot_:
|
|
153
|
+
idx += [next(gen_out) for _ in range(self.batch_size - n_in)]
|
|
154
|
+
idx = torch.tensor(idx)
|
|
155
|
+
yield enc["input_ids"][idx], enc["attention_mask"][idx], torch.from_numpy(y[idx.numpy()])
|
|
156
|
+
|
|
157
|
+
self.history_ = []
|
|
158
|
+
for ep in range(self.n_epochs):
|
|
159
|
+
self.history_.append(self._train_steps(batches(), opt))
|
|
160
|
+
self._log(f"epoch {ep + 1}/{self.n_epochs} loss {self.history_[-1]:.4f}")
|
|
161
|
+
return self._process_decision_scores(self.decision_function(X))
|
|
162
|
+
|
|
163
|
+
@torch.no_grad()
|
|
164
|
+
def decision_function(self, X: List[str], batch_size=16):
|
|
165
|
+
self.net_.eval()
|
|
166
|
+
X = list(X)
|
|
167
|
+
out = []
|
|
168
|
+
for s in range(0, len(X), batch_size):
|
|
169
|
+
t = self._tok(X[s:s + batch_size])
|
|
170
|
+
out.append(self.net_(t["input_ids"].to(self.device), t["attention_mask"].to(self.device))[0].cpu().numpy())
|
|
171
|
+
return np.concatenate(out)
|
pytextad/models/rsrae.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""RSRAE: Robust Subspace Recovery AutoEncoder (Lai, Zou & Lerman, ICLR 2020).
|
|
2
|
+
|
|
3
|
+
PyTorch port of the official TensorFlow code, https://github.com/dmzou/RSRAE
|
|
4
|
+
(MIT License, Copyright (c) 2019-present Chieh-Hsin Lai, Dongmian Zou and
|
|
5
|
+
Gilad Lerman); see THIRD_PARTY_NOTICES. Input is a vector per document
|
|
6
|
+
(sentence embeddings, TF-IDF, ...); RSRAE itself is not text-specific.
|
|
7
|
+
|
|
8
|
+
Faithful to the official code (RSRAE/model.py + experiments.py defaults)
|
|
9
|
+
* encoder d -> 32 -> 64 -> 128 (dense + activation + BN), RSR layer y A with
|
|
10
|
+
A ~ N(0, 1) of shape [128, intrinsic_size], L2 re-normalisation of z,
|
|
11
|
+
decoder 128 -> 64 -> 32 -> d (activation also on the output layer)
|
|
12
|
+
* activation chosen from the training data exactly as experiments.py does:
|
|
13
|
+
relu if min(X) >= 0, else tanh if |max(X)| <= 1, else leaky_relu(0.2)
|
|
14
|
+
* losses with the L2,1 norm (mean over samples of the non-squared L2 norm):
|
|
15
|
+
reconstruction ||x - x~||, PCA ||y - A A^T y||, projection mean((A^T A - I)^2)
|
|
16
|
+
* all_alt=True, enforce_proj=True (defaults): every mini-batch runs three Adam steps
|
|
17
|
+
with three separate optimisers: reconstruction on all weights (lr), projection on
|
|
18
|
+
A (10 lr), PCA error on encoder + A (10 lr)
|
|
19
|
+
* lr 2.5e-4, 200 epochs, batch 128, intrinsic size 10, Glorot-uniform weights,
|
|
20
|
+
zero biases, one shuffle before training (the official code does not reshuffle)
|
|
21
|
+
* anomaly score = -cos(x, x~)
|
|
22
|
+
* bn_mode="official": the official code calls Keras BatchNormalization in a TF1
|
|
23
|
+
graph without a training flag, so it always runs in inference mode with moving
|
|
24
|
+
statistics that are never updated (mean 0, variance 1). We verified this by
|
|
25
|
+
running the official code. The layer is therefore a trainable affine map
|
|
26
|
+
gamma * x / sqrt(1 + 1e-3) + beta, which is what "official" reproduces.
|
|
27
|
+
bn_mode="batch" uses real batch normalisation; bn_mode=None disables it.
|
|
28
|
+
|
|
29
|
+
Note on protocol: the official experiments fit RSRAE on the (contaminated) test set
|
|
30
|
+
and score the same data. Here fit() and decision_function() are separate, so use
|
|
31
|
+
fit(X).decision_scores_ for that transductive protocol.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import numpy as np
|
|
35
|
+
import torch
|
|
36
|
+
import torch.nn as nn
|
|
37
|
+
import torch.nn.functional as F
|
|
38
|
+
|
|
39
|
+
from .base import BaseTextDetector
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _FrozenStatBN(nn.Module):
|
|
43
|
+
"""Keras BatchNormalization in inference mode with untouched moving statistics."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, n, eps=1e-3):
|
|
46
|
+
super().__init__()
|
|
47
|
+
self.gamma = nn.Parameter(torch.ones(n))
|
|
48
|
+
self.beta = nn.Parameter(torch.zeros(n))
|
|
49
|
+
self.scale = 1.0 / np.sqrt(1.0 + eps)
|
|
50
|
+
|
|
51
|
+
def forward(self, x):
|
|
52
|
+
return self.gamma * x * self.scale + self.beta
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _bn(n, mode):
|
|
56
|
+
if mode is None:
|
|
57
|
+
return nn.Identity()
|
|
58
|
+
if mode == "official":
|
|
59
|
+
return _FrozenStatBN(n)
|
|
60
|
+
if mode == "batch":
|
|
61
|
+
return nn.BatchNorm1d(n, eps=1e-3, momentum=0.01) # Keras momentum 0.99
|
|
62
|
+
raise ValueError(f"unknown bn_mode {mode}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _dense(i, o):
|
|
66
|
+
layer = nn.Linear(i, o)
|
|
67
|
+
nn.init.xavier_uniform_(layer.weight)
|
|
68
|
+
nn.init.zeros_(layer.bias)
|
|
69
|
+
return layer
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _RSRAENet(nn.Module):
|
|
73
|
+
def __init__(self, d, hidden, intrinsic, act, normalize, bn_mode):
|
|
74
|
+
super().__init__()
|
|
75
|
+
h0, h1, h2 = hidden
|
|
76
|
+
self.act = act
|
|
77
|
+
self.normalize = normalize
|
|
78
|
+
self.enc = nn.ModuleList([_dense(d, h0), _dense(h0, h1), _dense(h1, h2)])
|
|
79
|
+
self.enc_bn = nn.ModuleList([_bn(h0, bn_mode), _bn(h1, bn_mode), _bn(h2, bn_mode)])
|
|
80
|
+
self.A = nn.Parameter(torch.randn(h2, intrinsic))
|
|
81
|
+
self.dec = nn.ModuleList([_dense(intrinsic, h2), _dense(h2, h1), _dense(h1, h0)])
|
|
82
|
+
self.dec_bn = nn.ModuleList([_bn(h2, bn_mode), _bn(h1, bn_mode), _bn(h0, bn_mode)])
|
|
83
|
+
self.out = _dense(h0, d)
|
|
84
|
+
|
|
85
|
+
def encode(self, x):
|
|
86
|
+
for lin, bn in zip(self.enc, self.enc_bn):
|
|
87
|
+
x = bn(self.act(lin(x)))
|
|
88
|
+
return x
|
|
89
|
+
|
|
90
|
+
def forward(self, x):
|
|
91
|
+
y = self.encode(x)
|
|
92
|
+
y_rsr = y @ self.A
|
|
93
|
+
z = F.normalize(y_rsr, p=2, dim=-1, eps=1e-12) if self.normalize else y_rsr
|
|
94
|
+
h = z
|
|
95
|
+
for lin, bn in zip(self.dec, self.dec_bn):
|
|
96
|
+
h = bn(self.act(lin(h)))
|
|
97
|
+
return y, y_rsr, z, self.act(self.out(h))
|
|
98
|
+
|
|
99
|
+
def encoder_params(self):
|
|
100
|
+
return list(self.enc.parameters()) + list(self.enc_bn.parameters())
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _norm_loss(diff, kind):
|
|
104
|
+
kind = kind.lower()
|
|
105
|
+
if kind in ("mse", "frob", "f"):
|
|
106
|
+
return diff.norm(dim=1).pow(2).mean()
|
|
107
|
+
if kind == "l1":
|
|
108
|
+
return diff.abs().sum(1).mean()
|
|
109
|
+
if kind in ("l21", "lad", "l2"):
|
|
110
|
+
return diff.norm(dim=1).mean()
|
|
111
|
+
raise ValueError(f"unknown norm {kind}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class RSRAE(BaseTextDetector):
|
|
115
|
+
|
|
116
|
+
def __init__(self, hidden_layer_sizes=(32, 64, 128), intrinsic_size=10,
|
|
117
|
+
loss_norm_type="L21", norm_type="L21", all_alt=True, enforce_proj=True,
|
|
118
|
+
lambda1=0.0025, lambda2=0.1, lr=2.5e-4, n_epochs=200, batch_size=128,
|
|
119
|
+
normalize=True, bn_mode="official", activation="auto",
|
|
120
|
+
contamination=0.1, random_state=0, device=None, verbose=False):
|
|
121
|
+
"""
|
|
122
|
+
activation : "auto" (official rule), or "relu" / "tanh" / "leaky_relu".
|
|
123
|
+
lambda1, lambda2 are only used when all_alt=False (joint loss), as in the
|
|
124
|
+
official code.
|
|
125
|
+
"""
|
|
126
|
+
super().__init__(contamination, random_state, device, verbose)
|
|
127
|
+
self.hidden_layer_sizes = tuple(hidden_layer_sizes)
|
|
128
|
+
self.intrinsic_size = intrinsic_size
|
|
129
|
+
self.loss_norm_type = loss_norm_type
|
|
130
|
+
self.norm_type = norm_type
|
|
131
|
+
self.all_alt = all_alt
|
|
132
|
+
self.enforce_proj = enforce_proj
|
|
133
|
+
self.lambda1 = lambda1
|
|
134
|
+
self.lambda2 = lambda2
|
|
135
|
+
self.lr = lr
|
|
136
|
+
self.n_epochs = n_epochs
|
|
137
|
+
self.batch_size = batch_size
|
|
138
|
+
self.normalize = normalize
|
|
139
|
+
self.bn_mode = bn_mode
|
|
140
|
+
self.activation = activation
|
|
141
|
+
|
|
142
|
+
def _pick_activation(self, X):
|
|
143
|
+
name = self.activation
|
|
144
|
+
if name == "auto":
|
|
145
|
+
if X.min() >= 0:
|
|
146
|
+
name = "relu"
|
|
147
|
+
elif abs(X.max()) <= 1: # official rule checks max only
|
|
148
|
+
name = "tanh"
|
|
149
|
+
else:
|
|
150
|
+
name = "leaky_relu"
|
|
151
|
+
self.activation_ = name
|
|
152
|
+
return {"relu": F.relu, "tanh": torch.tanh,
|
|
153
|
+
"leaky_relu": lambda t: F.leaky_relu(t, 0.2)}[name]
|
|
154
|
+
|
|
155
|
+
def _pca_error(self, y, y_rsr):
|
|
156
|
+
return _norm_loss(y - y_rsr @ self.net_.A.t(), self.norm_type)
|
|
157
|
+
|
|
158
|
+
def _proj_error(self):
|
|
159
|
+
A = self.net_.A
|
|
160
|
+
return torch.mean((A.t() @ A - torch.eye(A.shape[1], device=A.device)) ** 2)
|
|
161
|
+
|
|
162
|
+
def fit(self, X, y=None):
|
|
163
|
+
self._set_seed()
|
|
164
|
+
X = np.asarray(X, dtype=np.float32)
|
|
165
|
+
act = self._pick_activation(X)
|
|
166
|
+
self.net_ = _RSRAENet(X.shape[1], self.hidden_layer_sizes, self.intrinsic_size, act,
|
|
167
|
+
self.normalize, self.bn_mode).to(self.device)
|
|
168
|
+
Xt = torch.from_numpy(X).to(self.device)
|
|
169
|
+
idx = np.random.permutation(len(X)) # official: shuffled once
|
|
170
|
+
self._train_loop(Xt, idx)
|
|
171
|
+
return self._process_decision_scores(self.decision_function(X))
|
|
172
|
+
|
|
173
|
+
def _train_loop(self, Xt, idx):
|
|
174
|
+
net = self.net_
|
|
175
|
+
opt_main = torch.optim.Adam(net.parameters(), lr=self.lr, eps=1e-8)
|
|
176
|
+
opt_proj = torch.optim.Adam([net.A], lr=10 * self.lr, eps=1e-8)
|
|
177
|
+
opt_pca = torch.optim.Adam(net.encoder_params() + [net.A], lr=10 * self.lr, eps=1e-8)
|
|
178
|
+
|
|
179
|
+
n_batch = (len(Xt) - 1) // self.batch_size + 1
|
|
180
|
+
net.train()
|
|
181
|
+
for epoch in range(self.n_epochs):
|
|
182
|
+
for b in range(n_batch):
|
|
183
|
+
xb = Xt[idx[b * self.batch_size:(b + 1) * self.batch_size]]
|
|
184
|
+
yv, y_rsr, _, x_rec = net(xb)
|
|
185
|
+
loss = _norm_loss(xb - x_rec, self.loss_norm_type)
|
|
186
|
+
if not self.all_alt:
|
|
187
|
+
loss = loss + self.lambda1 * self._pca_error(yv, y_rsr) + self.lambda2 * self._proj_error()
|
|
188
|
+
opt_main.zero_grad()
|
|
189
|
+
loss.backward()
|
|
190
|
+
opt_main.step()
|
|
191
|
+
if self.all_alt and self.enforce_proj:
|
|
192
|
+
opt_proj.zero_grad()
|
|
193
|
+
self._proj_error().backward()
|
|
194
|
+
opt_proj.step()
|
|
195
|
+
if self.all_alt:
|
|
196
|
+
yv, y_rsr, _, _ = net(xb)
|
|
197
|
+
opt_pca.zero_grad()
|
|
198
|
+
self._pca_error(yv, y_rsr).backward()
|
|
199
|
+
opt_pca.step()
|
|
200
|
+
if self.verbose and (epoch + 1) % 20 == 0:
|
|
201
|
+
self._log(f"epoch {epoch + 1}/{self.n_epochs} last-batch loss {loss.item():.4f}")
|
|
202
|
+
|
|
203
|
+
@torch.no_grad()
|
|
204
|
+
def reconstruct(self, X):
|
|
205
|
+
self.net_.eval()
|
|
206
|
+
Xt = torch.as_tensor(np.asarray(X, dtype=np.float32), device=self.device)
|
|
207
|
+
return torch.cat([self.net_(Xt[s:s + 1024])[3] for s in range(0, len(Xt), 1024)]).cpu().numpy()
|
|
208
|
+
|
|
209
|
+
def decision_function(self, X):
|
|
210
|
+
X = np.asarray(X, dtype=np.float32)
|
|
211
|
+
R = self.reconstruct(X)
|
|
212
|
+
cos = (X * R).sum(1) / (np.linalg.norm(R, axis=1) + 1e-6) / (np.linalg.norm(X, axis=1) + 1e-6)
|
|
213
|
+
return -cos
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Frozen token-embedding extraction (input for CVDD and other token-sequence detectors).
|
|
2
|
+
|
|
3
|
+
Returns, per document, the last-layer hidden states of a frozen Hugging Face encoder,
|
|
4
|
+
as a float32 array of shape [n_tokens, dim], plus the word index of every subword
|
|
5
|
+
(so that token scores can be aggregated back to words).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import List, Sequence, Union
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import torch
|
|
12
|
+
|
|
13
|
+
Text = Union[str, Sequence[str]]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TokenEmbedder:
|
|
17
|
+
|
|
18
|
+
def __init__(self, model_name_or_path, layer=-1, max_length=512, batch_size=32,
|
|
19
|
+
keep_special_tokens=False, device=None, dtype=torch.float32, cache_dir=None):
|
|
20
|
+
"""
|
|
21
|
+
model_name_or_path : any Hugging Face encoder (bert-base-uncased, roberta-base, ...).
|
|
22
|
+
layer : hidden layer to return (-1 = last, as in official CVDD-BERT).
|
|
23
|
+
max_length : longer documents are truncated; the number truncated is
|
|
24
|
+
stored in ``n_truncated_`` after each call.
|
|
25
|
+
keep_special_tokens: keep [CLS]/[SEP] (<s>/</s>) vectors. Default False.
|
|
26
|
+
cache_dir : Hugging Face cache folder, as in from_pretrained(..., cache_dir=...).
|
|
27
|
+
"""
|
|
28
|
+
from transformers import AutoModel, AutoTokenizer
|
|
29
|
+
self._path, self._cache = model_name_or_path, cache_dir
|
|
30
|
+
self.tokenizer = AutoTokenizer.from_pretrained(model_name_or_path, use_fast=True, cache_dir=cache_dir)
|
|
31
|
+
if not self.tokenizer.is_fast:
|
|
32
|
+
raise ValueError("A fast tokenizer is required for word alignment.")
|
|
33
|
+
self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
|
34
|
+
try: # transformers >= 4.56 / 5.x
|
|
35
|
+
model = AutoModel.from_pretrained(model_name_or_path, dtype=dtype, cache_dir=cache_dir)
|
|
36
|
+
except TypeError:
|
|
37
|
+
model = AutoModel.from_pretrained(model_name_or_path, torch_dtype=dtype, cache_dir=cache_dir)
|
|
38
|
+
self.model = model.to(self.device).eval()
|
|
39
|
+
for p in self.model.parameters():
|
|
40
|
+
p.requires_grad = False
|
|
41
|
+
self.layer = layer
|
|
42
|
+
self.max_length = max_length
|
|
43
|
+
self.batch_size = batch_size
|
|
44
|
+
self.keep_special_tokens = keep_special_tokens
|
|
45
|
+
|
|
46
|
+
@torch.no_grad()
|
|
47
|
+
def transform(self, texts: List[Text]):
|
|
48
|
+
"""Returns (embeddings, word_ids): two lists with one entry per document."""
|
|
49
|
+
embs, wids = [], []
|
|
50
|
+
self.n_truncated_ = 0
|
|
51
|
+
for b in range(0, len(texts), self.batch_size):
|
|
52
|
+
batch = list(texts[b:b + self.batch_size])
|
|
53
|
+
split = not isinstance(batch[0], str)
|
|
54
|
+
if split and getattr(self.tokenizer, "add_prefix_space", None) is False:
|
|
55
|
+
# byte-level BPE tokenizers (RoBERTa, GPT-2) need this for pre-split words
|
|
56
|
+
from transformers import AutoTokenizer
|
|
57
|
+
self.tokenizer = AutoTokenizer.from_pretrained(self._path, use_fast=True, add_prefix_space=True,
|
|
58
|
+
cache_dir=self._cache)
|
|
59
|
+
enc = self.tokenizer(batch, is_split_into_words=split, padding=True, truncation=True,
|
|
60
|
+
max_length=self.max_length, return_tensors="pt",
|
|
61
|
+
return_special_tokens_mask=True, return_overflowing_tokens=False)
|
|
62
|
+
full = self.tokenizer(batch, is_split_into_words=split, add_special_tokens=True)["input_ids"]
|
|
63
|
+
self.n_truncated_ += sum(len(f) > self.max_length for f in full)
|
|
64
|
+
special = enc.pop("special_tokens_mask")
|
|
65
|
+
inputs = {k: v.to(self.device) for k, v in enc.items()}
|
|
66
|
+
out = self.model(**inputs, output_hidden_states=True)
|
|
67
|
+
H = out.hidden_states[self.layer].float().cpu().numpy()
|
|
68
|
+
for i in range(len(batch)):
|
|
69
|
+
keep = enc["attention_mask"][i].bool()
|
|
70
|
+
if not self.keep_special_tokens:
|
|
71
|
+
keep &= ~special[i].bool()
|
|
72
|
+
idx = keep.nonzero().squeeze(-1).numpy()
|
|
73
|
+
embs.append(H[i, idx].astype(np.float32))
|
|
74
|
+
wi = enc.word_ids(i)
|
|
75
|
+
wids.append([wi[j] for j in idx])
|
|
76
|
+
return embs, wids
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def mean_pool(token_embeddings):
|
|
80
|
+
"""Document vector = mean of its token vectors (for RSRAE and other vector detectors)."""
|
|
81
|
+
return np.stack([e.mean(0) for e in token_embeddings])
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def words_from_subwords(scores, word_ids, n_words=None, agg="max"):
|
|
85
|
+
"""Aggregate one document's subword scores to word scores (max or mean)."""
|
|
86
|
+
valid = [w for w in word_ids if w is not None]
|
|
87
|
+
n = n_words if n_words is not None else (max(valid) + 1 if valid else 0)
|
|
88
|
+
out = np.full(n, -np.inf) if agg == "max" else np.zeros(n)
|
|
89
|
+
cnt = np.zeros(n)
|
|
90
|
+
for s, w in zip(scores, word_ids):
|
|
91
|
+
if w is None:
|
|
92
|
+
continue
|
|
93
|
+
out[w] = max(out[w], s) if agg == "max" else out[w] + s
|
|
94
|
+
cnt[w] += 1
|
|
95
|
+
if agg == "mean":
|
|
96
|
+
out = out / np.maximum(cnt, 1)
|
|
97
|
+
out[cnt == 0] = np.nan # words lost to truncation have no score
|
|
98
|
+
return out
|
pytextad/version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pytextad
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A unified library for text anomaly detection (document- and token-level), in the style of PyOD
|
|
5
|
+
Author: Yang Cao
|
|
6
|
+
License-Expression: BSD-2-Clause
|
|
7
|
+
Project-URL: Homepage, https://github.com/charles-cao/pytextad
|
|
8
|
+
Project-URL: Documentation, https://pytextad.readthedocs.io
|
|
9
|
+
Keywords: anomaly detection,outlier detection,text,NLP,token-level
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
License-File: THIRD_PARTY_NOTICES.md
|
|
18
|
+
Requires-Dist: numpy>=1.21
|
|
19
|
+
Requires-Dist: scikit-learn>=1.0
|
|
20
|
+
Requires-Dist: torch>=1.13
|
|
21
|
+
Requires-Dist: transformers>=4.30
|
|
22
|
+
Provides-Extra: test
|
|
23
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
24
|
+
Provides-Extra: docs
|
|
25
|
+
Requires-Dist: sphinx>=7; extra == "docs"
|
|
26
|
+
Requires-Dist: furo; extra == "docs"
|
|
27
|
+
Requires-Dist: myst-parser; extra == "docs"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# PyTextAD: Text Anomaly Detection in Python
|
|
31
|
+
|
|
32
|
+
[](https://pypi.org/project/pytextad/)
|
|
33
|
+
[](https://pytextad.readthedocs.io)
|
|
34
|
+
[](https://github.com/charles-cao/pytextad/actions/workflows/tests.yml)
|
|
35
|
+
[](LICENSE)
|
|
36
|
+
|
|
37
|
+
**PyTextAD** is a Python library for detecting anomalies in text, at the document
|
|
38
|
+
and at the token level. Every detector follows the PyOD interface
|
|
39
|
+
(`fit`, `decision_function`, `predict`, `decision_scores_`, `labels_`), and every
|
|
40
|
+
re-implemented method is checked numerically against its original code.
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install pytextad
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
From source:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
git clone https://github.com/charles-cao/pytextad.git
|
|
52
|
+
cd pytextad
|
|
53
|
+
pip install -e ".[test]"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Requires Python >= 3.9, PyTorch >= 1.13 and transformers >= 4.30. Install the
|
|
57
|
+
PyTorch build that matches your CUDA version first (https://pytorch.org).
|
|
58
|
+
|
|
59
|
+
## Quick start
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from pytextad import CVDD, DATE, FATE, RSRAE, TokenEmbedder, mean_pool
|
|
63
|
+
|
|
64
|
+
# frozen token embeddings from any Hugging Face encoder
|
|
65
|
+
emb = TokenEmbedder("bert-base-uncased")
|
|
66
|
+
H_train, _ = emb.transform(train_texts)
|
|
67
|
+
H_test, _ = emb.transform(test_texts)
|
|
68
|
+
|
|
69
|
+
scores = CVDD().fit(H_train).decision_function(H_test) # higher = more anomalous
|
|
70
|
+
scores = RSRAE().fit(mean_pool(H_train)).decision_function(mean_pool(H_test))
|
|
71
|
+
scores = DATE().fit(train_texts).decision_function(test_texts) # raw text in, trains its own model
|
|
72
|
+
scores = FATE().fit(texts, y).decision_function(test_texts) # few-shot: y = 1 for labelled anomalies
|
|
73
|
+
|
|
74
|
+
word_scores = DATE().fit(train_texts).token_scores([t.split() for t in test_texts])
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
A runnable example on AG News: `python examples/quickstart.py`.
|
|
78
|
+
|
|
79
|
+
## Implemented methods
|
|
80
|
+
|
|
81
|
+
| Method | Year | Input | Token scores | Reference |
|
|
82
|
+
|---|---|---|---|---|
|
|
83
|
+
| CVDD | 2019 | frozen token embeddings | yes | Ruff et al., *Self-Attentive, Multi-Context One-Class Classification for Unsupervised Anomaly Detection on Text*, ACL 2019 |
|
|
84
|
+
| RSRAE | 2020 | document vectors | no | Lai et al., *Robust Subspace Recovery Layer for Unsupervised Anomaly Detection*, ICLR 2020 |
|
|
85
|
+
| DATE | 2021 | raw text | yes | Manolache et al., *DATE: Detecting Anomalies in Text via Self-Supervision of Transformers*, NAACL 2021 |
|
|
86
|
+
| FATE | 2023 | raw text (+ few labelled anomalies) | no | Das et al., *Few-shot Anomaly Detection in Text with Deviation Learning*, ICONIP 2023 |
|
|
87
|
+
|
|
88
|
+
Default hyperparameters are those of the official code. Each module's docstring
|
|
89
|
+
lists where the official code and the paper disagree and which one we follow.
|
|
90
|
+
|
|
91
|
+
## Faithfulness to the original implementations
|
|
92
|
+
|
|
93
|
+
`tests/verification/` runs each original implementation next to ours with the same
|
|
94
|
+
weights, inputs and random seeds and compares the results (DATE against the original
|
|
95
|
+
transformers 3.0.2 code, RSRAE against the original TensorFlow code). All checks
|
|
96
|
+
pass; see [tests/verification/README.md](tests/verification/README.md).
|
|
97
|
+
|
|
98
|
+
## Running the tests
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
pytest # fast API tests, a few seconds
|
|
102
|
+
PYTEXTAD_DEVICE=cuda pytest # same, on GPU (PowerShell: $env:PYTEXTAD_DEVICE="cuda"; pytest)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
BSD 2-Clause. Third-party notices for the original implementations are in
|
|
108
|
+
[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
pytextad/__init__.py,sha256=naJhcjwsw2ovsXhp7iClkqnZ-PYFUuBbM7ikF6PAoek,428
|
|
2
|
+
pytextad/version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
3
|
+
pytextad/models/__init__.py,sha256=RxAI0AKf2DexbxVA-MNWIxk2VQ-BFc32LBswFfdKy5w,194
|
|
4
|
+
pytextad/models/base.py,sha256=lOJeOfpb2pXv4CmsDvbz0qC0D86iGe-ycpzyKMFE9No,2841
|
|
5
|
+
pytextad/models/cvdd.py,sha256=LtJ3fERHIsgr2KCRAA5ilGGNiqMUx3o61SuU5XV5Ds8,10207
|
|
6
|
+
pytextad/models/date.py,sha256=zr74ZF_tCPPbS8AXfcm_KxaKAKCIAHAXK66l_UOYl0U,13431
|
|
7
|
+
pytextad/models/fate.py,sha256=fnwUjj1tWtH2zKpDsCErN1-1zOikTRqDpcLExckKPyM,8144
|
|
8
|
+
pytextad/models/rsrae.py,sha256=l4oyTZhhHhQLuuWmy16CGi4-LOchN0c1SEXyO9TYT8Y,9026
|
|
9
|
+
pytextad/utils/__init__.py,sha256=tGNt0TSrBTDdAqOFtN6lcCMuTl9O-4dX_a1Vr0FG8iU,135
|
|
10
|
+
pytextad/utils/embeddings.py,sha256=k-ftLzwaez6mMdEUHu8H1WdPF_pWtdaAxvLJ1Xjn_7I,5010
|
|
11
|
+
pytextad-0.1.0.dist-info/licenses/LICENSE,sha256=QNelLymU93gvw0MdZm0Bt02p4XCxk9XvBoGdszabVts,1297
|
|
12
|
+
pytextad-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md,sha256=8fUXxny_phNs7YLBfzphwK1DdSuCPxkAjbfXTgGDNG0,2839
|
|
13
|
+
pytextad-0.1.0.dist-info/METADATA,sha256=Sxk3SUqBKCxLyBpFD6-kyoolSvZcOh3pYCG04X0Ke8s,4611
|
|
14
|
+
pytextad-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
15
|
+
pytextad-0.1.0.dist-info/top_level.txt,sha256=sQuDfOeL6h0hBxFVdIeSG5efM45WYZBjqusAtvMoSrU,9
|
|
16
|
+
pytextad-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
BSD 2-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Yang Cao
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
16
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
17
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
18
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
19
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
20
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
21
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
22
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
23
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
24
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Third-party notices
|
|
2
|
+
|
|
3
|
+
## CVDD (pytextad/cvdd.py)
|
|
4
|
+
Re-implemented from https://github.com/lukasruff/CVDD-PyTorch
|
|
5
|
+
|
|
6
|
+
MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2019 lukasruff
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
15
|
+
|
|
16
|
+
## RSRAE (pytextad/rsrae.py)
|
|
17
|
+
Ported to PyTorch from https://github.com/dmzou/RSRAE
|
|
18
|
+
|
|
19
|
+
MIT License
|
|
20
|
+
|
|
21
|
+
Copyright (c) 2019-present Chieh-Hsin Lai, Dongmian Zou and Gilad Lerman
|
|
22
|
+
|
|
23
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
24
|
+
|
|
25
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
26
|
+
|
|
27
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
28
|
+
|
|
29
|
+
## DATE (pytextad/date.py)
|
|
30
|
+
Re-implemented from https://github.com/bit-ml/date, whose model code lives in a
|
|
31
|
+
modified copy of simpletransformers distributed under the Apache License 2.0.
|
|
32
|
+
No source code is copied; the logic was re-written against modern transformers.
|
|
33
|
+
|
|
34
|
+
## FATE (pytextad/fate.py)
|
|
35
|
+
Re-implemented from https://github.com/arav1ndajay/fate, which has no licence file.
|
|
36
|
+
No source code is copied; the logic was re-written and checked numerically against it.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pytextad
|