Text attacks differ from image attacks because text has no norm ball. An NLP adversarial example is a discrete edit that must pass a stack of heuristic meaning filters, and the filters decide the result: TextFooler’s defaults collapse BERT’s accuracy, while filters calibrated against human judgment cut its success rate by over 70 percentage points.
The discreteness problem
In vision, a perturbation is small in L-infinity or L2 norm, invisible, and two papers quoting epsilon = 8/255 quote the same threat. Text is discrete; there is no adding epsilon to the word “dog.” An adversarial example in text needs another definition of admissible change:
- Character-level: typos, homoglyphs, invisible or control characters a reader parses as unchanged
- Word-level: synonym substitution, dropping or inserting uninformative words
- Sentence-level: paraphrase that keeps the meaning and changes the surface form
Whether the result still means the same thing is a human judgment, which attacks approximate with automated filters. Encoding tricks that render to identical glyphs are valid by construction; everything else needs its constraints written down.
What TextFooler actually reported
TextFooler (Jin et al., AAAI 2020) ranks words by how much deleting each one changes the prediction, takes up to 50 nearest neighbors of the top word in counter-fitted GloVe space, filters them by part of speech and Universal Sentence Encoder (USE) similarity, keeps the candidate that most lowers the true-class confidence, and repeats until the label flips.
It attacked WordCNN, WordLSTM and BERT on five classification datasets (AG, Fake, MR, IMDB, Yelp), and InferSent, ESIM and BERT on SNLI and MultiNLI; RoBERTa and XLNet were not targets. The BERT results:
| Dataset | Avg. words | Original accuracy | After attack | Words changed | USE similarity |
|---|---|---|---|---|---|
| MR | 20 | 86.0% | 11.5% | 16.7% | 0.65 |
| IMDB | 215 | 90.9% | 13.6% | 6.1% | 0.86 |
| Yelp | 152 | 97.0% | 6.6% | 13.9% | 0.74 |
| SNLI | 8 | 89.4% | 4.0% | 18.5% | 0.45 |
| MNLI (m / mm) | 11 / 12 | 85.1% / 82.1% | 9.6% / 8.3% | 15.2% / 14.6% | 0.57 / 0.58 |
Two columns say more than the accuracy collapse. Short inputs lose a far larger share of their words: 6.1% of the average IMDB review is about 13 words, 16.7% of an MR review about three. The lowest similarity, 0.45, is on SNLI, the shortest inputs. Jin et al.’s human evaluation found 92% label agreement between original and adversarial text on MR and 85% on SNLI.
BERT-Attack (Li et al., EMNLP 2020) draws candidates from a masked language model instead; its substitutes are fluent, which is not the same as meaning-preserving.
One attack name, three constraint settings
“TextFooler” covers at least three configurations:
| Source | Word-embedding floor | USE check | Inputs under 15 words |
|---|---|---|---|
| Paper text | δ = 0.7, N = 50 | ”a preset threshold ε”, no value in the method description | Not mentioned |
| Released code | 0.5, hard-coded; 50 synonyms | 1 − θ ≥ 0.7 over a 15-word window, θ in radians (cosine ≥ 0.955) | Threshold drops to 0.1 (cosine ≥ 0.62), commented “shut down the similarity thresholding function” |
TextAttack textfooler | 0.5; 50 candidates | 1 − θ/π ≥ 0.840845057 over a 15-word window, against the previous step (cosine ≥ 0.878) | Check skipped: score set to 1 |
TextAttack’s source comment derives 0.840845057 as 1 − 0.5/π, correcting the released code’s missing division by π; that assumes 0.5 on the original scale, not the code’s 0.7 default. Checking each swap against the previous step also lets drift accumulate.
Li et al.’s unified defense benchmark (EMNLP 2021) pins the dial explicitly: USE similarity of at least 0.84 “following the setting in” Jin et al., 50 candidates, at most 30% (AG News) or 10% (IMDB) of words changed, and 50 queries per input word.
What the human studies found
Reevaluating Adversarial Examples in Natural Language (Morris et al., Findings of EMNLP 2020) measured the gap. For each attack and dataset, 100 successful examples got 10 Mechanical Turk ratings each on whether meaning was preserved, on a 1-to-5 scale with 4 as the proposed bar. TextFooler averaged 3.28, the genetic attack of Alzantot et al. 2.70. LanguageTool found more grammatical errors in the adversarial text than the original for 50% of TextFooler’s perturbations, and judges spotted computer-altered MR text with 69.2% accuracy.
Their tightened TFAdjusted required word-embedding cosine of at least 0.9, cosine of at least 0.98 on a BERT sentence encoder fine-tuned for semantic similarity (where judges on average agreed meaning survived), and a LanguageTool grammar check. Success against BERT:
| Dataset | TextFooler | TFAdjusted |
|---|---|---|
| IMDB | 85.0% | 13.9% |
| Yelp | 93.2% | 5.3% |
| MR | 86.6% | 10.6% |
| SNLI | 94.5% | 7.2% |
| MNLI | 95.1% | 14.8% |
AdvGLUE’s builders found the same from the other side: around 90% of the examples their 14 attack methods produced changed the meaning or misled human annotators too.
The constraint stack, side by side
In vision the constraint is one number; in text it is a stack of heuristics with free parameters, and the stack is what a success rate measures.
| Constraint | What it actually checks | What it lets through | What it wrongly rejects |
|---|---|---|---|
| Counter-fitted embedding neighbors (TextFooler candidates) | The substitute is near the original in a space tuned to pull synonyms together and push antonyms apart | Substitutes that are synonymous in isolation but wrong in this sentence | Valid context-specific paraphrases that sit far apart in a static embedding space |
| Part-of-speech match | The substitute keeps the slot’s grammatical category | Anything meaning-changing that shares the category; a POS filter has no opinion about polarity | Legitimate rewrites that change the category while preserving meaning |
| USE similarity above a threshold | A window of up to 15 words around the swap moved less than a chosen angle; in TextAttack, measured against the previous step | The drift the threshold tolerates, drift accumulated across steps, and short inputs the implementation exempts | Short windows, where one legitimate swap moves the vector a lot, whenever the check runs |
| Masked-LM likelihood (BERT-Attack substitutions) | The substitute is plausible in context to a language model | Fluent sentences that say something different; fluency is not meaning preservation | Rare but correct wording that the language model scores as unlikely |
Three consequences follow.
The threshold is a dial on the result. Tightening the stack cut TextFooler’s success rate against BERT by 71 to 88 points; a rate reported without its stack compares with nothing.
The filters are not calibrated to human judgment. None of the four rows asks whether a reader would keep the same label, and only a human study says whether a number reflects label agreement or preserved meaning.
The stack is part of the threat model. Fix it before measuring, write it into the evaluation spec, and report it with every number, the way a robust-accuracy figure carries its epsilon.
Worked example: turn the dial on one review
Jin et al.’s example table includes a negative MR review: “The characters, cast in impossibly contrived situations, are totally estranged from reality.” Against a WordLSTM, not BERT, TextFooler swapped in “engineered circumstances” and “fully” and flipped the label to positive. At 12 words, the review sits under the 15-word window.
Install with pip install "textattack[tensorflow]" (the USE constraint needs tensorflow_hub); TextAttack 0.3.11 (August 14, 2026) needs Python 3.9 or later. textattack attack --recipe textfooler --model bert-base-uncased-mr --num-examples 100 gives the stock baseline at dataset scale. dial.py rebuilds that recipe with its thresholds as arguments, reusing its stopword list and omitting only the NLI premise constraint.
# dial.py (TextAttack 0.3.11). Usage: python dial.py USE_MIN skip|noskip EMB_MIN
import math, sys
import numpy as np
import transformers
from textattack import Attack
from textattack.attack_recipes import TextFoolerJin2019
from textattack.constraints.grammaticality import PartOfSpeech
from textattack.constraints.pre_transformation import RepeatModification, StopwordModification
from textattack.constraints.semantics import WordEmbeddingDistance
from textattack.constraints.semantics.sentence_encoders import UniversalSentenceEncoder
from textattack.goal_functions import UntargetedClassification
from textattack.models.wrappers import HuggingFaceModelWrapper
from textattack.search_methods import GreedyWordSwapWIR
from textattack.shared import WordEmbedding
from textattack.transformations import WordSwapEmbedding
use_min, skip, emb_min = float(sys.argv[1]), sys.argv[2] == "skip", float(sys.argv[3])
TEXT = "The characters, cast in impossibly contrived situations, are totally estranged from reality."
NAME = "textattack/bert-base-uncased-rotten-tomatoes" # CLI alias: bert-base-uncased-mr
model = HuggingFaceModelWrapper(
transformers.AutoModelForSequenceClassification.from_pretrained(NAME),
transformers.AutoTokenizer.from_pretrained(NAME))
stock = TextFoolerJin2019.build(model)
stopwords = next(c for c in stock.pre_transformation_constraints
if isinstance(c, StopwordModification))
attack = Attack(
UntargetedClassification(model),
[RepeatModification(), stopwords,
WordEmbeddingDistance(min_cos_sim=emb_min),
PartOfSpeech(allow_verb_noun_swap=True),
UniversalSentenceEncoder(threshold=use_min, metric="angular",
compare_against_original=False, window_size=15,
skip_text_shorter_than_window=skip)],
WordSwapEmbedding(max_candidates=50),
GreedyWordSwapWIR(wir_method="delete"))
print(attack) # the constraint values actually in force
result = attack.attack(TEXT, 0) # ground truth: negative
print(type(result).__name__, result.goal_function_result_str())
orig, adv = result.original_result.attacked_text, result.perturbed_result.attacked_text
emb = WordEmbedding.counterfitted_GLOVE_embedding()
for i in sorted(orig.all_words_diff(adv)):
a, b = orig.words[i].lower(), adv.words[i].lower()
print(f"{a} -> {b}: counter-fitted cosine {emb.get_cos_sim(a, b):.3f}")
u = UniversalSentenceEncoder().encode([orig.text, adv.text])
cos = float(np.dot(u[0], u[1]) / (np.linalg.norm(u[0]) * np.linalg.norm(u[1])))
print(f"{len(orig.words)} words; USE cosine {cos:.3f}; "
f"angular {1 - math.acos(min(cos, 1.0)) / math.pi:.3f}")
Run it four times and record the result type, each substitution’s counter-fitted cosine, the whole-sentence USE cosine and angular similarity, and the output label with confidence. SkippedAttackResult means the model already misclassifies the review.
| Run | Command | What changes |
|---|---|---|
| A | python dial.py 0.840845057 skip 0.5 | Stock recipe values |
| B | python dial.py 0.936 skip 0.5 | USE floor raised to 0.936 angular, the equivalent of cosine 0.98 |
| C | python dial.py 0.936 noskip 0.5 | Same floor, now applied to the 12-word input |
| D | python dial.py 0.936 noskip 0.9 | Adds a word-embedding floor of 0.9 |
Reading the runs:
- B should reproduce A exactly. Below the window size every candidate scores 1, so the raised threshold is never consulted; if B differs, the skip logic changed in your version.
- C is the first run in which USE binds. A failed attack or different swaps mean the stock result depended on the filter being off.
- D bounds each swap. Every surviving substitution must show a counter-fitted cosine of at least 0.9.
Morris et al. applied 0.98 to a BERT-based encoder, not USE, so C and D are analogues of TFAdjusted, not replications. Report the gap between A and D with both stacks; at dataset scale, run both stacks on the same inputs and have people rate a sample of successes.
Benchmarks: AdvGLUE and DecodingTrust
AdvGLUE (Wang et al., NeurIPS 2021) is the closest thing NLP has to RobustBench: 14 attack methods at word, sentence and human-crafted levels across five GLUE tasks (SST-2, QQP, MNLI, QNLI, RTE), keeping an example only when at least four of five annotators agree it keeps the original label. Every model it tested scored far below its benign accuracy:
| Model | Benign GLUE avg | AdvGLUE avg | Source |
|---|---|---|---|
| BERT-large | 85.76 | 33.68 | AdvGLUE paper |
| ELECTRA-large | 93.16 | 41.69 | AdvGLUE paper |
| RoBERTa-large | 91.44 | 50.21 | AdvGLUE paper |
| DeBERTa-large | 92.67 | 60.86 | AdvGLUE paper |
| Top leaderboard entry (CASIA, July 2022) | n/a | 65.45 | Leaderboard |
| GPT-3.5, best prompt template | n/a | 67.37 | DecodingTrust |
| GPT-4, best prompt template | n/a | 78.41 | DecodingTrust |
Robust training bought little: SMART on BERT scored 30.29, below plain BERT. The leaderboard’s newest entry is that July 2022 submission; the public dev split, 738 examples in six configurations, is on Hugging Face as AI-Secure/adv_glue.
DecodingTrust (Wang et al., NeurIPS 2023) measured the GPT rows on the AdvGLUE test set and cites 65.77% for the leaderboard’s best model (the page shows 65.45). The largest drop from benign accuracy across prompt templates was 14.43% for GPT-3.5 and 9.90% for GPT-4, against 26.89% for that model; AdvGLUE predates both, so these are transfer results. AdvGLUE++ generates fresh attacks against Alpaca-7B, Vicuna-13B and StableVicuna-13B with TextBugger, TextFooler, BERT-ATTACK, SememePSO and SemAttack: SemAttack text built on Alpaca reached 89.2% attack success against GPT-4 on QQP, and BERT-ATTACK text built on Vicuna 100% against GPT-3.5 on MNLI-mm. The files are under data/adv-glue-plus-plus/ in the DecodingTrust repository.
A benchmark score is still a claim about an attack; evaluating adversarial robustness without fooling yourself applies, with the constraint stack added to the threat model.
Defenses compared
Text defenses train against attacks, certify against a defined set, or detect perturbed inputs. As with randomized smoothing on images, a certificate is exact about its set and silent outside it.
| Defense | Threat covered | Formal certificate | Clean-accuracy cost | Compute | Known adaptive breaks and limits |
|---|---|---|---|---|---|
| Adversarial training (A2T) | Swaps like its own attack (embedding cosine ≥ 0.8, sentence similarity ≥ 0.9, ≤ 10% of words) | No | Held or improved in most reported settings | Training only; its attack took 5,988 s on 1,000 IMDB samples, TextFooler 17,268 s | Fixed attacks only; best AT variant in Li et al.: 45.3% under TextFooler (IMDB) |
| IBP (Jia et al.) | Swaps among each word’s 8 nearest counter-fitted neighbors, language-model filtered | Yes: exact, every combination of in-set swaps | IMDB CNN: 89% clean falls to 81% | Epochs 2 to 4 times slower; no transformers in the paper | None inside the set; TextFooler’s 50-candidate pool reaches outside it |
| SAFER (Ye et al.) | Swaps within a synonym set the defender must know (GloVe cosine ≥ 0.8, top 100 per word) | Yes, probabilistic, via random word-substitution smoothing; in-set only, assumes symmetric synonymy | 91.5% vs 92.1% on IMDB (Li et al.) | 5,000 Monte Carlo samples per certified prediction | Synonyms from another source fall outside it; 39.5% under TextFooler on IMDB (Li et al.) |
| RanMASK (Zeng et al.) | Edits to up to k words, word or character level, no synonym list | Yes, probabilistic, in words: over 50% of texts at 5 words (AG News), 2 (SST-2) | 91.7% vs 94.5% on AG News (Li et al.) | 1,000 samples per prediction, 5,000 to certify | TextFooler’s average AG edit (about 9 of 43 words) exceeds the radius; 22.0% under TextFooler on IMDB (Li et al.) |
| Detection (FGWS, DISP) | FGWS: swaps that bring in rare words; DISP: tokens a learned discriminator flags | No | FGWS is tuned to flag at most 10% of clean validation inputs | FGWS: one extra forward pass; DISP: two extra models | FGWS: a frequent-words-only attacker held a protected Word-CNN to 42.7% to 50.9% accuracy on AG News, vs 34.8% to 43.6% undefended (RS&V); DISP not tested there |
SAFER smooths by replacing each word at random from its perturbation set; RanMASK masks a fraction of words, so it needs no synonym list. Figures marked Li et al. are accuracies at one shared attack budget; that benchmark’s strongest defense was FreeLB++, a modified adversarial training method, at 93.2% clean and 45.3% under TextFooler on IMDB versus 92.1% and 10.3% for plain BERT.
Character-level attacks and a Unicode hardening recipe
White-box character attacks such as HotFlip (Ebrahimi et al., ACL 2018) choose flips from the gradients of one-hot inputs; the ones that matter in deployment need no gradients. Bad Characters (Boucher et al., IEEE S&P 2022) uses four encoding tricks that leave rendered text unchanged: invisible characters (ZWSP, ZWNJ, ZWJ), homoglyphs from Unicode’s confusables data, reorderings through bidi controls (RLO, LRO, PDF, LRI, PDI), and deletions through backspace, DEL and carriage return. One injection significantly degraded vulnerable models and three broke most, including commercial systems from Microsoft and Google.
The sanitizer pins its data files to the Unicode version Python ships (3.14’s unicodedata is UCD 16.0.0), since UTS #39 warns that skeletons are not stable across versions. Run it identically in training and inference.
import unicodedata, urllib.request
UCD = "16.0.0" # = unicodedata.unidata_version on Python 3.14
def rows(url):
for line in urllib.request.urlopen(url).read().decode("utf-8-sig").splitlines():
line = line.split("#", 1)[0].strip()
if line:
yield [field.strip() for field in line.split(";")]
IGNORABLE = set()
for cps, prop, *_ in rows(f"https://www.unicode.org/Public/{UCD}/ucd/DerivedCoreProperties.txt"):
if prop == "Default_Ignorable_Code_Point":
lo, _, hi = cps.partition("..")
IGNORABLE.update(map(chr, range(int(lo, 16), int(hi or lo, 16) + 1)))
PROTOTYPE = {chr(int(src, 16)): "".join(chr(int(cp, 16)) for cp in dst.split())
for src, dst, *_ in rows(f"https://www.unicode.org/Public/security/{UCD}/confusables.txt")}
# UAX #9 embeddings, overrides, isolates and implicit marks
BIDI = set(map(chr, [0x202A, 0x202B, 0x202C, 0x202D, 0x202E, 0x2066,
0x2067, 0x2068, 0x2069, 0x200E, 0x200F, 0x061C]))
def sanitize(text):
"""Model input plus flags. A flag means block or review, not cleaned."""
flags = set()
if "\x08" in text or "\x7f" in text or "\r" in text.replace("\r\n", ""):
flags.add("deletion-control")
if BIDI & set(text):
flags.add("bidi-control")
text = "".join(ch for ch in text if ch not in IGNORABLE)
return unicodedata.normalize("NFKC", text), flags
def skeleton(text):
"""UTS #39 internalSkeleton: a comparison key, never model input."""
text = unicodedata.normalize("NFD", text)
text = "".join(PROTOTYPE.get(ch, ch) for ch in text if ch not in IGNORABLE)
return unicodedata.normalize("NFD", text)
| Step | Stops | Misses or breaks |
|---|---|---|
| Flag backspace, DEL and lone CR | Deletions; Boucher et al. suggest resolving them before inference or warning | Stripping instead leaves the characters meant to be erased |
| Flag bidi controls | Reorderings, as a block | Stripping alone leaves the scrambled logical order the model reads |
| Strip Default_Ignorable_Code_Point | Invisible characters, including the paper’s ZWSP, ZWNJ and ZWJ | Legitimate joiners, such as ZWJ in emoji sequences |
| NFKC | Compatibility lookalikes: ℌ to H, ① to 1 | Cross-script homoglyphs (Cyrillic а is its own letter); UAX #15 warns it can erase distinctions that matter |
Compare skeleton() keys | Listed cross-script lookalikes: “paypal” against “pаypаl” with Cyrillic а | Pairs missing from confusables.txt; skeletons are comparison keys, not a normalization or display form |
UTS #39, now at version 18.0.0, also defines a fuller skeleton that first reorders text for display with the bidirectional algorithm, which Python’s standard library does not implement. The heavyweight alternative is Boucher et al.’s: render the text and OCR it back, which they report fully prevents invisible-character, reordering and deletion attacks and strongly mitigates most homoglyphs, at 6.2% lower baseline BLEU.
Test it the way it will be attacked. TextAttack’s bad-characters recipe (BadCharacters2021) covers all four classes through perturbation_type (homoglyphs, invisible, deletions, reorderings) with differential evolution. Wrap the classifier so every query passes through sanitize(), attack raw and wrapped models at perturbs=1 and 3, and record per class the success rate and the share of successful inputs that raised a flag, which counts as a block.
Prompt injection and GCG, briefly
Prompt injection and optimized suffixes are adversarial text judged on behavior rather than a label, with no pretense of imperceptibility. GCG (Zou et al., 2023) combines greedy and gradient-based token search to find a suffix that maximizes the probability of an affirmative response; suffixes trained on Vicuna-7B and 13B transferred to the public interfaces of ChatGPT, Bard and Claude. Mechanics and cost are in how the GCG optimizer finds adversarial suffixes; retrieval and memory as the delivery path, in stored prompt injection through agent memory.
Decision procedure: what to test for your deployment
Match the deployment to a perturbation class, fix the constraints, then pick the tool; adversarial attack libraries covers where TextAttack fits.
| Deployment | Test first | Fix before measuring | Tool or recipe |
|---|---|---|---|
| Content moderation, toxicity filtering | Imperceptible encodings, then visible typos | Rendered text identical to the original; 1 and 3 injections; success is a benign verdict | bad-characters per perturbation_type, raw vs sanitized; deepwordbug for typos |
| Sentiment, NLI and other classifiers | Word substitution | Embedding and sentence-encoder floors with the short-input skip off, cap on words changed, POS, grammar check | textfooler, bert-attack, a2t at stock and tightened stacks; AdvGLUE dev split as a fixed reference |
| LLM app with retrieval | Instructions planted in retrieved documents, hidden-character payloads, optimized suffixes, transferred word-level attacks | Success as behavior (a tool call, a policy breach), not a label; imperceptibility only for hidden payloads | Sanitizer on retrieved chunks as well as user input; AdvGLUE++ for classification-style prompts; GCG for suffixes |
Whatever the row, write the stack into the evaluation spec before the first run, run stock and tightened stacks on the same inputs, have people rate a sample of successes on the 1-to-5 meaning scale, and report robust accuracy with the stack attached. Imperceptible-encoding attacks skip the rating: render and compare.
FAQ
is there a robustbench for nlp?
The closest equivalent is AdvGLUE, which applies 14 attack methods to five GLUE tasks and keeps only examples that human annotators validated. Its leaderboard’s newest entry dates from July 2022, so for LLMs add DecodingTrust’s AdvGLUE and AdvGLUE++ results and your own evaluation under a written constraint stack.
does unicode normalization stop homoglyph attacks?
Only partly. NFKC folds compatibility variants such as ℌ to H or ① to 1, but cross-script lookalikes such as Cyrillic а survive because they are distinct letters. Compare UTS #39 confusable skeletons to catch those, and treat invisible characters, bidi reorderings and deletion controls as separate steps, since NFKC touches none of them.
can nlp models be certified robust?
Yes, within a stated perturbation set. IBP certifies every combination of swaps from a fixed neighbor list, SAFER certifies substitutions from a defender-known synonym set by randomized smoothing, and RanMASK certifies edits to a few words with no synonym list, over half of texts at five words on AG News. None certifies paraphrase.