| """ |
| Modal app: Style-SFT training for BART-large on A100 80GB. |
| |
| Trains BARTWithStyleEmbeddings on HC3 AI→Human parallel corpus. |
| Output: fine-tuned model checkpoint uploaded to HuggingFace. |
| |
| Budget: ~15-25€, max 8h A100 80GB. |
| |
| Usage: |
| # Dry-run (50 steps, local validation, 0€) |
| modal run src/modal_app_sft.py --dry-run |
| |
| # Real training on A100 |
| modal run src/modal_app_sft.py --data datasets/style_transfer_pairs_train.jsonl |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import os |
| import sys |
| import time |
| from dataclasses import asdict, dataclass |
| from pathlib import Path |
|
|
| import modal |
|
|
| |
| |
| |
|
|
| image = ( |
| modal.Image.debian_slim(python_version="3.12") |
| .env({"PIP_PROGRESS_BAR": "off", "PYTHONIOENCODING": "utf-8"}) |
| .pip_install( |
| "torch>=2.4.0", |
| "transformers>=4.45.0", |
| "accelerate>=0.34.0", |
| "datasets>=3.0.0", |
| "numpy>=1.26.0", |
| ) |
| ) |
|
|
| app = modal.App("evasion-detection-sft", image=image) |
|
|
| |
| hf_cache = modal.Volume.from_name("hf-cache", create_if_missing=True) |
|
|
|
|
| |
| |
| |
|
|
| @dataclass |
| class CostGuard: |
| max_runtime_hours: float = 8.0 |
| max_cost_eur: float = 25.0 |
| gpu_type: str = "A100-80GB" |
| dry_run: bool = True |
| dry_run_max_steps: int = 50 |
|
|
| def validate(self, elapsed_hours: 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_hours > self.max_runtime_hours: |
| print(f"[CostGuard] TIMEOUT: {elapsed_hours:.1f}h > {self.max_runtime_hours}h") |
| return False |
| cost = elapsed_hours * rate |
| if cost > self.max_cost_eur: |
| print(f"[CostGuard] OVER BUDGET: {cost:.2f}EUR > {self.max_cost_eur}EUR") |
| return False |
| return True |
|
|
|
|
| |
| |
| |
|
|
| @dataclass |
| class TrainingConfig: |
| model_name: str = "facebook/bart-large" |
| output_dir: str = "/tmp/bart_sft_output" |
| batch_size: int = 8 |
| gradient_accumulation_steps: int = 4 |
| learning_rate: float = 2e-5 |
| warmup_steps: int = 200 |
| max_steps: int = 5000 |
| eval_steps: int = 500 |
| save_steps: int = 1000 |
| max_input_length: int = 512 |
| max_output_length: int = 512 |
| seed: int = 42 |
| fp16: bool = True |
| style_id_ai: int = 0 |
| style_id_human: int = 1 |
| |
| hf_repo: str = "simonlesaumon/evasion-detection-models" |
| hf_model_name: str = "bart-sft-style-humanization" |
|
|
|
|
| |
| |
| |
|
|
| def load_training_data(data_path: str, tokenizer, max_input_length: int, max_output_length: int, |
| style_token_ai: str = "<ai>", style_token_human: str = "<human>"): |
| """Load style transfer pairs from HF dataset path or local JSONL.""" |
| import torch |
| from torch.utils.data import Dataset |
|
|
| class StyleTransferDataset(Dataset): |
| def __init__(self, samples, tokenizer, max_input_length, max_output_length, |
| style_ai, style_human): |
| self.tokenizer = tokenizer |
| self.max_input_length = max_input_length |
| self.max_output_length = max_output_length |
| self.style_ai = style_ai |
| self.style_human = style_human |
| self.samples = samples |
|
|
| |
| for tok in [style_ai, style_human]: |
| if tok not in tokenizer.get_vocab(): |
| tokenizer.add_tokens([tok]) |
|
|
| def __len__(self): |
| return len(self.samples) |
|
|
| def __getitem__(self, idx): |
| sample = self.samples[idx] |
| |
| input_text = f"{self.style_human} {sample['ai_text']}" |
| target_text = sample["human_text"] |
|
|
| inputs = self.tokenizer( |
| input_text, |
| max_length=self.max_input_length, |
| truncation=True, |
| padding="max_length", |
| return_tensors="pt", |
| ) |
|
|
| targets = self.tokenizer( |
| target_text, |
| max_length=self.max_output_length, |
| truncation=True, |
| padding="max_length", |
| return_tensors="pt", |
| ) |
|
|
| return { |
| "input_ids": inputs["input_ids"].squeeze(0), |
| "attention_mask": inputs["attention_mask"].squeeze(0), |
| "labels": targets["input_ids"].squeeze(0), |
| } |
|
|
| |
| samples = [] |
| if data_path.startswith("datasets/"): |
| |
| import requests |
| hf_token = os.getenv("HF_TOKEN", "") |
| headers = {} |
| if hf_token: |
| headers["Authorization"] = f"Bearer {hf_token}" |
|
|
| url = f"https://huggingface.co/simonlesaumon/evasion-detection-artifacts/resolve/main/{data_path}" |
| print(f"[SFT] Downloading data from {url}...") |
| resp = requests.get(url, headers=headers, timeout=60) |
|
|
| if resp.status_code == 200: |
| for line in resp.text.splitlines(): |
| if line.strip(): |
| try: |
| samples.append(json.loads(line)) |
| except json.JSONDecodeError: |
| continue |
| print(f"[SFT] Downloaded {len(samples)} samples from HF") |
| else: |
| print(f"[SFT] HTTP {resp.status_code} downloading data, using fallback") |
| samples = _get_fallback_samples() |
| elif os.path.exists(data_path): |
| with open(data_path, "r", encoding="utf-8") as f: |
| for line in f: |
| if line.strip(): |
| samples.append(json.loads(line)) |
| print(f"[SFT] Loaded {len(samples)} samples from {data_path}") |
| else: |
| |
| print(f"[SFT] WARNING: Data not found at {data_path}, using synthetic fallback") |
| samples = _get_fallback_samples() |
|
|
| print(f"[SFT] Loaded {len(samples)} training samples") |
| return StyleTransferDataset(samples, tokenizer, max_input_length, max_output_length, |
| style_token_ai, style_token_human) |
|
|
|
|
| def _get_fallback_samples() -> list[dict]: |
| """Synthetic fallback pairs if dataset not available.""" |
| return [ |
| {"ai_text": "The implementation of machine learning algorithms has demonstrated " |
| "significant improvements in various domains. These systems leverage " |
| "large datasets to identify patterns and make predictions with high accuracy.", |
| "human_text": "So I tried using ML for this project and honestly it worked way better " |
| "than I expected. You feed it a bunch of data and it somehow figures out " |
| "patterns you'd never spot manually.", |
| "domain": "tech"}, |
| ] * 100 |
|
|
|
|
| |
| |
| |
|
|
| def create_style_model(model_name: str = "facebook/bart-large"): |
| """Create BART model with trainable style embeddings.""" |
| import torch |
| import torch.nn as nn |
| from transformers import AutoModelForSeq2SeqLM |
|
|
| class BARTWithStyleEmbeddings(nn.Module): |
| def __init__(self, model_name="facebook/bart-large", style_dim=1024, num_styles=2): |
| super().__init__() |
| self.bart = AutoModelForSeq2SeqLM.from_pretrained(model_name) |
| self.config = self.bart.config |
| hidden_size = self.config.d_model |
| self.style_embeddings = nn.Embedding(num_styles, hidden_size) |
| self.style_proj = nn.Sequential( |
| nn.Linear(hidden_size, style_dim), |
| nn.GELU(), |
| nn.Linear(style_dim, hidden_size), |
| ) |
| nn.init.normal_(self.style_embeddings.weight, std=0.02) |
| self._style_injection = None |
|
|
| def set_style(self, style_id: int): |
| """Set the style to inject during generation.""" |
| style_emb = self.style_embeddings(torch.tensor([style_id])) |
| self._style_injection = self.style_proj(style_emb) |
|
|
| def forward(self, input_ids, attention_mask=None, labels=None, style_ids=None): |
| encoder_outputs = self.bart.model.encoder( |
| input_ids=input_ids, attention_mask=attention_mask, return_dict=True, |
| ) |
| if style_ids is not None: |
| style_emb = self.style_embeddings(style_ids) |
| style_emb = self.style_proj(style_emb) |
| encoder_outputs.last_hidden_state = ( |
| encoder_outputs.last_hidden_state + style_emb.unsqueeze(1) |
| ) |
| decoder_outputs = self.bart( |
| encoder_outputs=encoder_outputs, |
| attention_mask=attention_mask, |
| labels=labels, |
| return_dict=True, |
| ) |
| return decoder_outputs |
|
|
| def generate(self, input_ids, attention_mask=None, max_length=512, **kwargs): |
| """Generate with cached style injection via encoder outputs modification.""" |
| encoder_outputs = self.bart.model.encoder( |
| input_ids=input_ids, attention_mask=attention_mask, return_dict=True, |
| ) |
| if self._style_injection is not None: |
| style_emb = self._style_injection.to(encoder_outputs.last_hidden_state.device) |
| encoder_outputs.last_hidden_state = ( |
| encoder_outputs.last_hidden_state + style_emb.unsqueeze(1) |
| ) |
| return self.bart.generate( |
| encoder_outputs=encoder_outputs, |
| attention_mask=attention_mask, |
| max_length=max_length, |
| **kwargs, |
| ) |
|
|
| def save_pretrained(self, path: str): |
| os.makedirs(path, exist_ok=True) |
| self.bart.save_pretrained(path) |
| torch.save({ |
| "style_embeddings": self.style_embeddings.state_dict(), |
| "style_proj": self.style_proj.state_dict(), |
| }, os.path.join(path, "style_modules.pt")) |
|
|
| @classmethod |
| def from_pretrained(cls, path: str, model_name: str = "facebook/bart-large"): |
| import torch as t |
| model = cls(model_name) |
| model.bart = AutoModelForSeq2SeqLM.from_pretrained(path) |
| ckpt = t.load(os.path.join(path, "style_modules.pt"), map_location="cpu") |
| model.style_embeddings.load_state_dict(ckpt["style_embeddings"]) |
| model.style_proj.load_state_dict(ckpt["style_proj"]) |
| return model |
|
|
| return BARTWithStyleEmbeddings(model_name) |
|
|
|
|
| |
| |
| |
|
|
| def train_sft_on_gpu( |
| data_path: str, |
| config: TrainingConfig, |
| cost_guard: CostGuard, |
| ) -> dict: |
| """Run Style-SFT training on GPU.""" |
| import torch |
| from transformers import AutoTokenizer, get_linear_schedule_with_warmup |
| from torch.utils.data import DataLoader |
|
|
| torch.manual_seed(config.seed) |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") |
| print(f"[SFT] Device: {device}") |
| print(f"[SFT] Config: {config}") |
|
|
| |
| tokenizer = AutoTokenizer.from_pretrained(config.model_name) |
|
|
| |
| dataset = load_training_data( |
| data_path, tokenizer, config.max_input_length, config.max_output_length |
| ) |
|
|
| if cost_guard.dry_run: |
| print(f"[SFT] DRY RUN: limiting to {cost_guard.dry_run_max_steps} steps") |
| dataset.samples = dataset.samples[: min(20, len(dataset.samples))] |
| config.max_steps = cost_guard.dry_run_max_steps |
|
|
| dataloader = DataLoader(dataset, batch_size=config.batch_size, shuffle=True, num_workers=2) |
|
|
| |
| print(f"[SFT] Creating model: {config.model_name}") |
| BARTModel = create_style_model(config.model_name) |
| model = BARTModel |
| model.to(device) |
|
|
| |
| if len(tokenizer) > model.config.vocab_size: |
| model.bart.resize_token_embeddings(len(tokenizer)) |
|
|
| |
| optimizer = torch.optim.AdamW(model.parameters(), lr=config.learning_rate) |
| scheduler = get_linear_schedule_with_warmup( |
| optimizer, |
| num_warmup_steps=config.warmup_steps, |
| num_training_steps=config.max_steps, |
| ) |
|
|
| |
| model.train() |
| global_step = 0 |
| total_loss = 0.0 |
| best_loss = float("inf") |
| start_time = time.time() |
|
|
| print(f"[SFT] Starting training: {config.max_steps} steps, " |
| f"batch={config.batch_size}, grad_accum={config.gradient_accumulation_steps}") |
|
|
| scaler = torch.amp.GradScaler("cuda") if config.fp16 and device.type == "cuda" else None |
|
|
| for epoch in range(10): |
| for batch in dataloader: |
| |
| if global_step % 100 == 0: |
| elapsed_h = (time.time() - start_time) / 3600 |
| if not cost_guard.validate(elapsed_h): |
| return {"status": "aborted_cost_guard", "step": global_step} |
|
|
| batch = {k: v.to(device) for k, v in batch.items()} |
| style_ids = torch.full( |
| (batch["input_ids"].shape[0],), config.style_id_human, |
| dtype=torch.long, device=device, |
| ) |
|
|
| if scaler is not None: |
| with torch.amp.autocast("cuda"): |
| outputs = model( |
| input_ids=batch["input_ids"], |
| attention_mask=batch["attention_mask"], |
| labels=batch["labels"], |
| style_ids=style_ids, |
| ) |
| loss = outputs.loss / config.gradient_accumulation_steps |
| scaler.scale(loss).backward() |
| else: |
| outputs = model( |
| input_ids=batch["input_ids"], |
| attention_mask=batch["attention_mask"], |
| labels=batch["labels"], |
| style_ids=style_ids, |
| ) |
| loss = outputs.loss / config.gradient_accumulation_steps |
| loss.backward() |
|
|
| total_loss += loss.item() |
|
|
| if (global_step + 1) % config.gradient_accumulation_steps == 0: |
| if scaler is not None: |
| scaler.unscale_(optimizer) |
| torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) |
| if scaler is not None: |
| scaler.step(optimizer) |
| scaler.update() |
| else: |
| optimizer.step() |
| scheduler.step() |
| optimizer.zero_grad() |
|
|
| global_step += 1 |
|
|
| |
| if global_step % config.eval_steps == 0: |
| avg_loss = total_loss / config.eval_steps |
| elapsed_h = (time.time() - start_time) / 3600 |
| cost = elapsed_h * 2.50 |
| print(f"[SFT] Step {global_step}/{config.max_steps} | " |
| f"Loss: {avg_loss:.4f} | " |
| f"Time: {elapsed_h:.1f}h | " |
| f"Cost: ~{cost:.2f}EUR | " |
| f"LR: {scheduler.get_last_lr()[0]:.2e}") |
| total_loss = 0.0 |
|
|
| if avg_loss < best_loss: |
| best_loss = avg_loss |
|
|
| |
| if global_step % config.save_steps == 0: |
| ckpt_path = os.path.join(config.output_dir, f"checkpoint-{global_step}") |
| model.save_pretrained(ckpt_path) |
| tokenizer.save_pretrained(ckpt_path) |
| print(f"[SFT] Checkpoint saved: {ckpt_path}") |
|
|
| if global_step >= config.max_steps: |
| break |
|
|
| if global_step >= config.max_steps: |
| break |
|
|
| |
| final_path = os.path.join(config.output_dir, "final") |
| model.save_pretrained(final_path) |
| tokenizer.save_pretrained(final_path) |
|
|
| elapsed_h = (time.time() - start_time) / 3600 |
| result = { |
| "status": "completed", |
| "total_steps": global_step, |
| "best_loss": best_loss, |
| "elapsed_hours": round(elapsed_h, 2), |
| "estimated_cost_eur": round(elapsed_h * 2.50, 2), |
| "final_model_path": final_path, |
| "config": asdict(config), |
| } |
| print(f"[SFT] Training done: {json.dumps(result, indent=2)}") |
| return result |
|
|
|
|
| |
| |
| |
|
|
| def upload_to_hf(local_dir: str, repo_id: str, model_name: str) -> str: |
| """Upload trained model to HuggingFace.""" |
| import subprocess |
|
|
| hf_token = os.getenv("HF_TOKEN", "") |
| if not hf_token: |
| print("[SFT] WARNING: HF_TOKEN not set. Skipping upload.") |
| return "skipped: no HF_TOKEN" |
|
|
| print(f"[SFT] Uploading model to {repo_id}/{model_name}...") |
| try: |
| |
| subprocess.run([ |
| "huggingface-cli", "upload", repo_id, local_dir, model_name, |
| "--token", hf_token, |
| ], check=True) |
| print(f"[SFT] Uploaded to https://huggingface.co/{repo_id}") |
| return f"https://huggingface.co/{repo_id}/tree/main/{model_name}" |
| except Exception as e: |
| print(f"[SFT] Upload failed: {e}") |
| return f"upload_failed: {e}" |
|
|
|
|
| |
| |
| |
|
|
| @app.function( |
| gpu="A100-80GB", |
| timeout=60 * 60 * 10, |
| scaledown_window=60 * 10, |
| volumes={"/root/.cache/huggingface": hf_cache}, |
| ) |
| def train_style_sft( |
| data_path: str = "datasets/style_transfer_pairs_train.jsonl", |
| model_name: str = "facebook/bart-large", |
| max_steps: int = 5000, |
| batch_size: int = 8, |
| learning_rate: float = 2e-5, |
| dry_run: bool = False, |
| push_to_hf: bool = True, |
| ) -> dict: |
| """Run Style-SFT training on A100 80GB.""" |
| config = TrainingConfig( |
| model_name=model_name, |
| max_steps=max_steps, |
| batch_size=batch_size, |
| learning_rate=learning_rate, |
| ) |
|
|
| guard = CostGuard( |
| dry_run=dry_run, |
| gpu_type="A100-80GB", |
| ) |
|
|
| result = train_sft_on_gpu(data_path, config, guard) |
|
|
| if result["status"] == "completed" and push_to_hf and not dry_run: |
| hf_url = upload_to_hf( |
| os.path.join(config.output_dir, "final"), |
| config.hf_repo, |
| config.hf_model_name, |
| ) |
| result["hf_url"] = hf_url |
|
|
| |
| os.makedirs(config.output_dir, exist_ok=True) |
| with open(os.path.join(config.output_dir, "train_result.json"), "w") as f: |
| json.dump(result, f, indent=2) |
|
|
| return result |
|
|
|
|
| |
| |
| |
|
|
| @app.local_entrypoint() |
| def main( |
| data: str = "datasets/style_transfer_pairs_train.jsonl", |
| model: str = "facebook/bart-large", |
| max_steps: int = 5000, |
| batch_size: int = 8, |
| lr: float = 2e-5, |
| dry_run: bool = False, |
| push_to_hf: bool = False, |
| ): |
| """Local entrypoint — dispatches training to Modal A100.""" |
| print("=" * 60) |
| print(" Style-SFT Training — Evasion Detection") |
| print("=" * 60) |
| print(f" Model: {model}") |
| print(f" Data: {data}") |
| print(f" Steps: {max_steps}") |
| print(f" Batch: {batch_size}") |
| print(f" LR: {lr}") |
| print(f" Dry-run: {dry_run}") |
| print(f" Push to HF: {push_to_hf}") |
| print("=" * 60) |
|
|
| if dry_run: |
| print("\n[SFT] DRY RUN — validating pipeline (no A100, ~50 steps on T4)...") |
| result = train_style_sft.local( |
| data_path=data, |
| model_name=model, |
| max_steps=50, |
| batch_size=2, |
| learning_rate=lr, |
| dry_run=True, |
| push_to_hf=False, |
| ) |
| else: |
| print("\n[SFT] Launching training on Modal A100-80GB (~15-25€, 6-8h)...") |
| result = train_style_sft.remote( |
| data_path=data, |
| model_name=model, |
| max_steps=max_steps, |
| batch_size=batch_size, |
| learning_rate=lr, |
| dry_run=False, |
| push_to_hf=push_to_hf, |
| ) |
|
|
| print(f"\n[SFT] Result: {json.dumps(result, indent=2, default=str)}") |
|
|
| if result.get("status") == "completed": |
| print(f"\n[SFT] Training complete! Best loss: {result.get('best_loss', 'N/A')}") |
| print(f"[SFT] Time: {result.get('elapsed_hours', 'N/A')}h") |
| print(f"[SFT] Cost: ~{result.get('estimated_cost_eur', 'N/A')}EUR") |
| if result.get("hf_url"): |
| print(f"[SFT] Model: {result['hf_url']}") |
|
|
| return result |
|
|