evasion-detection-artifacts / src /modal_app_copa.py
simonlesaumon's picture
Upload src/modal_app_copa.py with huggingface_hub
711bdce verified
Raw
History Blame Contribute Delete
13.6 kB
"""
Modal app: CoPA (Contrastive Paraphrase Attack) on GPU.
Deploys the CoPA prototype to Modal cloud — no local torch needed.
Dry-run mode: just validates the pipeline without launching real GPU.
Usage:
# Dry-run (local validation, 0€)
modal run src/modal_app_copa.py --dry-run
# Real run on T4 GPU (~0.60€/h)
modal run src/modal_app_copa.py
# Real run on A100 80GB (~2.50€/h)
modal run src/modal_app_copa.py --gpu A100-80GB
"""
from __future__ import annotations
import json
import os
import sys
import time
from dataclasses import asdict, dataclass
import modal
# ---------------------------------------------------------------------------
# Modal image with dependencies
# ---------------------------------------------------------------------------
image = (
modal.Image.debian_slim(python_version="3.12")
.env({"PIP_PROGRESS_BAR": "off"})
.pip_install(
"torch>=2.4.0",
"transformers>=4.45.0",
"accelerate>=0.34.0",
"numpy>=1.26.0",
)
)
app = modal.App("evasion-detection-copa", image=image)
# ---------------------------------------------------------------------------
# CostGuard (lightweight, no deps beyond stdlib)
# ---------------------------------------------------------------------------
@dataclass
class CostGuard:
max_runtime_minutes: int = 30
max_cost_eur: float = 5.0
gpu_type: str = "T4"
dry_run: bool = True
def validate(self, elapsed_m: float) -> bool:
rates = {"T4": 0.60, "L4": 0.80, "A10G": 1.10, "A100-80GB": 2.50, "H100": 3.95}
rate = rates.get(self.gpu_type, 2.50)
if elapsed_m > self.max_runtime_minutes:
print(f"[CostGuard] TIMEOUT: {elapsed_m:.0f}m > {self.max_runtime_minutes}m")
return False
cost = (elapsed_m / 60) * rate
if cost > self.max_cost_eur:
print(f"[CostGuard] OVER BUDGET: {cost:.2f}€ > {self.max_cost_eur}€")
return False
return True
# ---------------------------------------------------------------------------
# CoPA logic (runs INSIDE Modal container, so torch is available)
# ---------------------------------------------------------------------------
def _run_copa_on_gpu(
texts: list[str],
model_name: str = "Qwen/Qwen2.5-1.5B-Instruct",
lambda_contrast: float = 0.8,
alpha_truncation: float = 1e-5,
max_new_tokens: int = 768,
temperature: float = 1.0,
top_p: float = 0.92,
repetition_penalty: float = 1.15,
diversity_bonus_strength: float = 0.5,
human_style_prompt: str = "",
machine_style_prompt: str = "",
dry_run: bool = False,
) -> dict:
"""Core CoPA rewriting — executed inside Modal GPU container."""
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
if dry_run:
return {
"status": "dry_run",
"num_samples": len(texts),
"model": model_name,
"message": "Dry-run: GPU not used, no text generated.",
}
print(f"[CoPA-Modal] Loading {model_name}...")
tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
model = AutoModelForCausalLM.from_pretrained(
model_name,
torch_dtype=torch.float16,
device_map="auto",
trust_remote_code=True,
)
model.eval()
if human_style_prompt == "":
human_style_prompt = (
"Rewrite this to sound like a natural human wrote it, "
"with varied sentences and conversational wording:\n\n{input_text}"
)
if machine_style_prompt == "":
machine_style_prompt = (
"Repeat the following text exactly, word for word, "
"maintaining the original formal structure:\n\n{input_text}"
)
results = []
total_tokens = 0
start = time.time()
for i, text in enumerate(texts):
# Format prompts with actual input (few-shot uses {input_text} placeholder)
h_text = human_style_prompt.replace("{input_text}", text)
m_text = machine_style_prompt.replace("{input_text}", text)
h_in = tokenizer(h_text, return_tensors="pt").to(model.device)
m_in = tokenizer(m_text, return_tensors="pt").to(model.device)
generated = []
with torch.no_grad():
for step in range(max_new_tokens):
h_logits = model(**h_in).logits[0, -1, :] / temperature
m_logits = model(**m_in).logits[0, -1, :] / temperature
# Contrastive combination
h_lp = torch.log_softmax(h_logits, dim=-1)
m_lp = torch.log_softmax(m_logits, dim=-1)
contrastive = (1 + lambda_contrast) * h_lp - lambda_contrast * m_lp
# Repetition penalty
if repetition_penalty != 1.0 and generated:
for gid in set(generated):
if contrastive[gid] > 0:
contrastive[gid] /= repetition_penalty
else:
contrastive[gid] *= repetition_penalty
# Adaptive truncation
h_probs = torch.softmax(h_logits, dim=-1)
mask = h_probs >= alpha_truncation * h_probs.max()
contrastive[~mask] = float("-inf")
# Top-p nucleus filtering
if top_p < 1.0:
sorted_logits, sorted_indices = torch.sort(contrastive, descending=True)
cumulative_probs = torch.cumsum(torch.softmax(sorted_logits, dim=-1), dim=-1)
sorted_indices_to_remove = cumulative_probs > top_p
sorted_indices_to_remove[0] = False # keep at least one token
indices_to_remove = sorted_indices[sorted_indices_to_remove]
contrastive[indices_to_remove] = float("-inf")
# Diversity bonus: penalize tokens used in last 20 positions
if diversity_bonus_strength > 0 and len(generated) >= 3:
recent_window = generated[-20:]
for gid in set(recent_window):
contrastive[gid] -= diversity_bonus_strength * recent_window.count(gid)
probs = torch.softmax(contrastive, dim=-1)
next_id = torch.multinomial(probs, 1).item()
generated.append(next_id)
# Append
h_in["input_ids"] = torch.cat(
[h_in["input_ids"], torch.tensor([[next_id]], device=model.device)], dim=1
)
h_in["attention_mask"] = torch.ones_like(h_in["input_ids"])
m_in["input_ids"] = torch.cat(
[m_in["input_ids"], torch.tensor([[next_id]], device=model.device)], dim=1
)
m_in["attention_mask"] = torch.ones_like(m_in["input_ids"])
if next_id == tokenizer.eos_token_id:
break
rewritten = tokenizer.decode(generated, skip_special_tokens=True)
# Post-processing: strip template artifacts
import re
cut_patterns = [
r"\n\s*Text:\s", r"\n\s*Human version:", r"\n\s*Formal",
r"###\s", r"\n\s*You are an AI",
]
for pattern in cut_patterns:
m = re.search(pattern, rewritten)
if m:
rewritten = rewritten[: m.start()].strip()
break
# Remove trailing incomplete sentence
if rewritten and rewritten[-1] not in '.!?':
last_period = max(rewritten.rfind('.'), rewritten.rfind('!'), rewritten.rfind('?'))
if last_period > len(rewritten) * 0.6:
rewritten = rewritten[: last_period + 1]
results.append({"original": text, "rewritten": rewritten.strip(), "tokens": len(generated)})
total_tokens += len(generated)
if (i + 1) % 10 == 0:
print(f"[CoPA-Modal] {i+1}/{len(texts)} done")
elapsed = time.time() - start
print(f"[CoPA-Modal] Done: {len(results)} samples, {total_tokens} tokens, {elapsed:.1f}s")
return {
"status": "completed",
"num_samples": len(results),
"total_tokens": total_tokens,
"elapsed_seconds": elapsed,
"tokens_per_second": total_tokens / elapsed if elapsed > 0 else 0,
"config": {
"model": model_name,
"lambda_contrast": lambda_contrast,
"top_p": top_p,
"repetition_penalty": repetition_penalty,
"diversity_bonus": diversity_bonus_strength,
"max_new_tokens": max_new_tokens,
},
"results": results,
}
# ---------------------------------------------------------------------------
# Modal function
# ---------------------------------------------------------------------------
@app.function(
gpu=os.getenv("MODAL_COPA_GPU", "T4"),
timeout=60 * 30, # 30 min max
scaledown_window=60 * 5,
)
def copa_rewrite_modal(
texts: list[str],
model_name: str = "Qwen/Qwen2.5-1.5B-Instruct",
lambda_contrast: float = 0.8,
alpha_truncation: float = 1e-5,
max_new_tokens: int = 768,
temperature: float = 1.0,
top_p: float = 0.92,
repetition_penalty: float = 1.15,
diversity_bonus_strength: float = 0.5,
dry_run: bool = False,
) -> dict:
"""Run CoPA rewriting on Modal GPU."""
guard = CostGuard(
gpu_type=os.getenv("MODAL_COPA_GPU", "T4"),
dry_run=dry_run,
)
return _run_copa_on_gpu(
texts=texts,
model_name=model_name,
lambda_contrast=lambda_contrast,
alpha_truncation=alpha_truncation,
max_new_tokens=max_new_tokens,
temperature=temperature,
top_p=top_p,
repetition_penalty=repetition_penalty,
diversity_bonus_strength=diversity_bonus_strength,
dry_run=dry_run,
)
# ---------------------------------------------------------------------------
# Local entrypoint
# ---------------------------------------------------------------------------
@app.local_entrypoint()
def main(
num_samples: int = 10,
model_name: str = "Qwen/Qwen2.5-1.5B-Instruct",
gpu: str = "T4",
dry_run: bool = False,
text: str = "",
text_file: str = "",
):
"""Local entrypoint — builds test texts and dispatches to Modal GPU.
Use --text "Your custom text here" for single-text rewrite.
Use --text-file path/to/file.txt to load from a file.
"""
os.environ["MODAL_COPA_GPU"] = gpu
# Determine source texts
if text:
texts = [text]
print(f"[Modal-Copa] Custom text mode: {len(text[0].split())} words")
elif text_file:
with open(text_file, "r", encoding="utf-8") as f:
content = f.read().strip()
texts = [content]
print(f"[Modal-Copa] Loaded text from {text_file}: {len(content.split())} words")
else:
# Generate test texts (synthetic AI-like)
templates = [
"Artificial intelligence has revolutionized natural language processing in recent years. The development of large language models has enabled unprecedented capabilities in text generation, translation, and summarization tasks.",
"Climate change represents one of the most significant challenges facing humanity. Rising global temperatures have led to increasingly severe weather events and disruptions to ecosystems worldwide.",
"The history of computer science can be traced back to the foundational work of Alan Turing. His theoretical contributions laid the groundwork for the digital revolution that followed.",
"Machine learning algorithms have demonstrated remarkable success across a wide range of applications. These systems learn patterns from large datasets to make accurate predictions.",
"The Renaissance period marked a profound transformation in European art, science, and philosophy. This cultural movement began in Italy during the fourteenth century.",
]
texts = [templates[i % len(templates)] for i in range(num_samples)]
print(f"[Modal-Copa] Dispatching {len(texts)} samples to Modal GPU={gpu}")
print(f"[Modal-Copa] Model: {model_name}")
print(f"[Modal-Copa] Dry-run: {dry_run}")
if dry_run:
result = {
"status": "dry_run_validated",
"num_samples": len(texts),
"model": model_name,
"gpu": gpu,
"message": "Dry-run: Modal image built and ready. Run without --dry-run for real GPU execution.",
}
else:
result = copa_rewrite_modal.remote(
texts=texts,
model_name=model_name,
dry_run=False,
)
# Save output
os.makedirs("output", exist_ok=True)
out_path = "output/copa_modal_results.json"
with open(out_path, "w", encoding="utf-8") as f:
json.dump(result, f, indent=2, ensure_ascii=False)
print(f"[Modal-Copa] Results saved to {out_path}")
print(f"[Modal-Copa] Status: {result.get('status')}")
if result.get("status") == "completed":
for i, r in enumerate(result["results"][:3]):
print(f"\n--- Sample {i+1} ---")
print(f" Original ({len(r['original'].split())}w): {r['original'][:120]}...")
# Encode to ASCII for Windows console, replace unicode chars
safe_text = r['rewritten'][:300].encode('ascii', errors='replace').decode('ascii')
print(f" Rewritten ({r['tokens']}t): {safe_text}")