Spaces:
Paused
Paused
Download adversarial.py from Rasheed/Test_AI_writing: direct link, hf CLI and curl.
- Browser
- Download file 8.31 kB
-
https://huggingface.co/spaces/Rasheed/Test_AI_writing/resolve/main/adversarial.py
- Command line
-
hf download hf://spaces/Rasheed/Test_AI_writing/adversarial.py
-
curl -L -o adversarial.py https://huggingface.co/spaces/Rasheed/Test_AI_writing/resolve/main/adversarial.py
8.31 kB
| """ | |
| Adversarial Paraphrasing (detector-guided decoding) for the Parrot paraphraser. | |
| Technique from "Adversarial Paraphrasing: A Universal Attack for Humanizing AI-Generated Text" (Cheng et al., 2025, | |
| arXiv:2506.07001): while the paraphraser writes, the AI detector chooses the words. At each decoding step the | |
| paraphraser proposes its most likely next tokens (top-k inside top-p); every proposal is appended to the text written | |
| so far and scored by the detector, and the proposal that reads as the MOST HUMAN is kept. The paraphraser keeps the | |
| sentence fluent and on-meaning (only its own likely tokens are allowed); the detector steers word choice. | |
| Here it adds one extra candidate per sentence next to Parrot's sampled versions. That candidate goes through exactly | |
| the same checks as every other version (terms / numbers / citations restored and guarded, meaning, fluency, clarity | |
| gate, Groq check), so a damaged adversarial sentence is simply not chosen. | |
| Cost: one detector batch per guided step (sentences x candidates texts). On a dedicated GPU this is roughly 2-3x the | |
| time of normal paraphrasing for the sentences it is used on; on CPU it is much slower, so by default it runs only in | |
| the "100% AI-written" / report-guided modes and on at most ADV_MAX sentence pieces per job. | |
| Space variables | |
| HUM_ADV ai (default: 100% AI mode and report-guided rewriting) | always | off | |
| ADV_K candidate tokens scored per step (default 8; the paper uses 50) | |
| ADV_TOP_P nucleus for the candidates (default 0.95) | |
| ADV_MIN_P a candidate needs at least this share of the top token's probability (default 0.05): keeps the | |
| detector from picking an unlikely, ungrammatical word | |
| ADV_EVERY guide every n-th token (default 1 = every token, as in the paper; 2 halves the detector cost) | |
| ADV_BATCH sentences decoded together (default 16) | |
| ADV_MAX most sentence pieces per job (default 400) | |
| ADV_ON_ZERO 1 = also run on ZeroGPU hardware (on the CPU copy of the model; slow). Default 0. | |
| """ | |
| import os | |
| MODE = os.getenv("HUM_ADV", "ai").strip().lower() | |
| K = max(2, int(os.getenv("ADV_K", "8"))) | |
| TOP_P = float(os.getenv("ADV_TOP_P", "0.95")) | |
| MIN_P = float(os.getenv("ADV_MIN_P", "0.05")) | |
| EVERY = max(1, int(os.getenv("ADV_EVERY", "1"))) | |
| BATCH = max(1, int(os.getenv("ADV_BATCH", "16"))) | |
| MAX_PIECES = max(0, int(os.getenv("ADV_MAX", "400"))) | |
| ON_ZERO = os.getenv("ADV_ON_ZERO", "0") == "1" | |
| STATS = {"runs": 0, "pieces": 0, "steps": 0, "scored": 0, "errors": 0, "last_error": ""} | |
| def wanted(full_ai): | |
| """Is detector-guided decoding used for this job?""" | |
| if MODE in ("off", "0", "no", "false"): | |
| return False | |
| return MODE == "always" or bool(full_ai) | |
| def _no_repeat(prev_ids, n=3): | |
| """Tokens that would repeat an n-gram already written (same rule as Parrot's no_repeat_ngram_size=3).""" | |
| if len(prev_ids) < n - 1: | |
| return set() | |
| tail = tuple(prev_ids[-(n - 1):]) | |
| banned = set() | |
| for i in range(len(prev_ids) - n + 1): | |
| if tuple(prev_ids[i:i + n - 1]) == tail: | |
| banned.add(prev_ids[i + n - 1]) | |
| return banned | |
| def generate(texts, model, tokenizer, device, score_fn, temperature=1.0, prefix="paraphrase: "): | |
| """Detector-guided paraphrase of each text (one output per text; the input text where decoding fails). | |
| score_fn(list[str]) -> list[float] AI probabilities (lower = more human), or None.""" | |
| import torch | |
| outs = list(texts) | |
| if not texts: | |
| return outs | |
| STATS["runs"] += 1 | |
| temperature = max(0.3, float(temperature)) | |
| order = sorted(range(len(texts)), key=lambda i: len(texts[i])) # similar lengths decode together | |
| for b0 in range(0, len(order), BATCH): | |
| ids_ = order[b0:b0 + BATCH] | |
| batch = [texts[i] for i in ids_] | |
| try: | |
| res = _generate_batch(batch, model, tokenizer, device, score_fn, temperature, prefix, torch) | |
| except Exception as err: # never break the job: Parrot's normal versions remain | |
| STATS["errors"] += 1 | |
| STATS["last_error"] = str(err)[:200] | |
| print(f"[adversarial] batch skipped: {err}", flush=True) | |
| continue | |
| for i, r in zip(ids_, res): | |
| if r and r.strip(): | |
| outs[i] = r.strip() | |
| STATS["pieces"] += len(batch) | |
| return outs | |
| def _generate_batch(batch, model, tokenizer, device, score_fn, temperature, prefix, torch): | |
| enc = tokenizer([prefix + t for t in batch], return_tensors="pt", padding=True, truncation=True, max_length=256) | |
| enc = {k: v.to(device) for k, v in enc.items()} | |
| n = len(batch) | |
| max_new = min(180, int(enc["input_ids"].shape[1] * 1.35) + 12) | |
| eos = tokenizer.eos_token_id | |
| start = model.config.decoder_start_token_id | |
| if start is None: | |
| start = tokenizer.pad_token_id | |
| written = [[] for _ in range(n)] | |
| done = [False] * n | |
| with torch.inference_mode(): | |
| enc_out = model.get_encoder()(input_ids=enc["input_ids"], attention_mask=enc["attention_mask"]) | |
| last = torch.full((n, 1), start, dtype=torch.long, device=device) | |
| past = None | |
| for step in range(max_new): | |
| out = model(encoder_outputs=enc_out, attention_mask=enc["attention_mask"], decoder_input_ids=last, | |
| past_key_values=past, use_cache=True) | |
| past = out.past_key_values | |
| probs = torch.softmax(out.logits[:, -1, :].float() / temperature, dim=-1) | |
| top_p, top_i = probs.topk(K, dim=-1) | |
| top_p, top_i = top_p.cpu().tolist(), top_i.cpu().tolist() | |
| choice = [eos] * n | |
| texts, owners = [], [] | |
| for b in range(n): | |
| if done[b]: | |
| continue | |
| banned = _no_repeat(written[b]) | |
| cands, cum = [], 0.0 | |
| for p, t in zip(top_p[b], top_i[b]): | |
| if t in banned: | |
| continue | |
| if cands and (cum >= TOP_P or p < MIN_P * top_p[b][0]): | |
| break | |
| cands.append((t, p)) | |
| cum += p | |
| if not cands: | |
| cands = [(top_i[b][0], top_p[b][0])] | |
| choice[b] = cands[0][0] # most likely token by default | |
| if len(cands) > 1 and step % EVERY == 0: | |
| for t, _ in cands: | |
| if t == eos: # the sentence so far, as it would end here | |
| texts.append(tokenizer.decode(written[b], skip_special_tokens=True)) | |
| else: | |
| texts.append(tokenizer.decode(written[b] + [t], skip_special_tokens=True)) | |
| owners.append((b, t)) | |
| if texts: | |
| sc = score_fn([t if t.strip() else "." for t in texts]) | |
| STATS["scored"] += len(texts) | |
| if sc: | |
| best = {} | |
| for (b, t), s in zip(owners, sc): | |
| if b not in best or s < best[b][0] - 1e-6: # ties keep the more likely (earlier) token | |
| best[b] = (s, t) | |
| for b, (_, t) in best.items(): | |
| choice[b] = t | |
| STATS["steps"] += 1 | |
| for b in range(n): | |
| if done[b]: | |
| choice[b] = tokenizer.pad_token_id | |
| continue | |
| if choice[b] == eos: | |
| done[b] = True | |
| else: | |
| written[b].append(choice[b]) | |
| if all(done): | |
| break | |
| last = torch.tensor([[c] for c in choice], dtype=torch.long, device=device) | |
| return [tokenizer.decode(w, skip_special_tokens=True) for w in written] | |
| def status(): | |
| if not wanted(True): | |
| return "Adversarial paraphrasing: off (HUM_ADV=off)." | |
| s = STATS | |
| return (f"Adversarial paraphrasing ({MODE}, k={K}, every {EVERY} token): {s['pieces']} sentence pieces, " | |
| f"{s['scored']:,} detector checks" + (f"; last error: {s['last_error']}" if s["last_error"] else ".")) | |