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 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"]
@@ -0,0 +1,7 @@
1
+ from .base import BaseTextDetector
2
+ from .cvdd import CVDD
3
+ from .date import DATE
4
+ from .fate import FATE
5
+ from .rsrae import RSRAE
6
+
7
+ __all__ = ["BaseTextDetector", "CVDD", "DATE", "FATE", "RSRAE"]
@@ -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}")
@@ -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
@@ -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)]
@@ -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)
@@ -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,3 @@
1
+ from .embeddings import TokenEmbedder, mean_pool, words_from_subwords
2
+
3
+ __all__ = ["TokenEmbedder", "mean_pool", "words_from_subwords"]
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/pytextad.svg)](https://pypi.org/project/pytextad/)
33
+ [![Documentation](https://readthedocs.org/projects/pytextad/badge/?version=latest)](https://pytextad.readthedocs.io)
34
+ [![Tests](https://github.com/charles-cao/pytextad/actions/workflows/tests.yml/badge.svg)](https://github.com/charles-cao/pytextad/actions/workflows/tests.yml)
35
+ [![License](https://img.shields.io/badge/license-BSD--2--Clause-blue.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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