Spaces:
Sleeping
Sleeping
Download main.py from lymnal/neuroscope-api: direct link, hf CLI and curl.
- Browser
- Download file 20.3 kB
-
https://huggingface.co/spaces/lymnal/neuroscope-api/resolve/main/main.py
- Command line
-
hf download hf://spaces/lymnal/neuroscope-api/main.py
-
curl -L -o main.py https://huggingface.co/spaces/lymnal/neuroscope-api/resolve/main/main.py
20.3 kB
| """ | |
| NeuroScope-Web API Server | |
| FastAPI endpoints that expose Moon's research functions to the React frontend. | |
| This replaces the browser-based transformers.js worker with server-side | |
| TransformerLens inference. | |
| Run with: uvicorn main:app --reload --port 8000 | |
| API docs: http://localhost:8000/docs | |
| """ | |
| import os | |
| from typing import List, Literal, Optional | |
| from fastapi import FastAPI, HTTPException | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from pydantic import BaseModel, Field | |
| import model | |
| import patching | |
| import research | |
| import refusal_pairs as refusal_pairs_module | |
| from refusal_bench.harmfulness_probe import ( | |
| evaluate_probe, | |
| extract_last_token_residuals, | |
| extract_with_ablation, | |
| train_probe, | |
| ) | |
| from refusal_bench.runner import run_bench, serialize as serialize_bench | |
| from refusal_bench.techniques import TECHNIQUES | |
| # ----------------------------------------------------------------------------- | |
| # App Setup | |
| # ----------------------------------------------------------------------------- | |
| app = FastAPI( | |
| title="NeuroScope-Web API", | |
| description="Moon's interpretability research powered by TransformerLens", | |
| version="0.1.0", | |
| ) | |
| # CORS origins are configurable so the same image runs locally and on HF Spaces. | |
| # Set ALLOWED_ORIGINS to your deployed frontend URL(s), e.g. | |
| # ALLOWED_ORIGINS=https://neuroscope.vercel.app,https://preview.neuroscope.vercel.app | |
| _default_origins = "http://localhost:3000,http://localhost:3001" | |
| ALLOWED_ORIGINS = [ | |
| origin.strip() | |
| for origin in os.environ.get("ALLOWED_ORIGINS", _default_origins).split(",") | |
| if origin.strip() | |
| ] | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=ALLOWED_ORIGINS, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # ----------------------------------------------------------------------------- | |
| # Request/Response Models | |
| # ----------------------------------------------------------------------------- | |
| # Layer/head upper bounds cover GPT-2-small (12L, 12H), Llama-3.2-1B | |
| # (16L, 32H), and Llama-3.2-3B (28L, 24H). Static pydantic caps validate | |
| # request shape; per-model bounds are enforced at runtime via _validate_layer. | |
| class LoadRequest(BaseModel): | |
| model_name: str = Field(default="gpt2-small", description="Model to load") | |
| class PromptRequest(BaseModel): | |
| prompt: str = Field(..., description="Input text to analyze") | |
| class GradientRequest(BaseModel): | |
| prompt: str | |
| target_token: str = Field(..., description="Token to compute gradients toward") | |
| class AttentionRequest(BaseModel): | |
| prompt: str | |
| layer: int = Field(ge=0, le=27, description="Layer index (model-dependent)") | |
| head: int = Field(ge=0, le=31, description="Head index (model-dependent)") | |
| class SteeringRequest(BaseModel): | |
| positive_prompts: List[str] = Field(..., min_length=1) | |
| negative_prompts: List[str] = Field(..., min_length=1) | |
| layer: int = Field(default=6, ge=0, le=27, description="Layer for extraction") | |
| class SteeredGenerationRequest(BaseModel): | |
| prompt: str | |
| steering_vector: List[float] | |
| alpha: float = Field(default=1.0, description="Steering strength") | |
| layer: int = Field(default=6, ge=0, le=27) | |
| max_new_tokens: int = Field(default=30, ge=1, le=100) | |
| seed: int = Field(default=42, ge=0, description="RNG seed used when do_sample=True") | |
| do_sample: bool = Field( | |
| default=False, | |
| description="Greedy by default so the before/after differs only by the intervention", | |
| ) | |
| class AblationRequest(BaseModel): | |
| prompt: str | |
| direction: List[float] = Field( | |
| ..., description="Direction to project out of the residual stream" | |
| ) | |
| layer: int = Field(default=6, ge=0, le=27, description="Layer for ablation") | |
| max_new_tokens: int = Field(default=30, ge=1, le=100) | |
| seed: int = Field(default=42, ge=0, description="RNG seed used when do_sample=True") | |
| do_sample: bool = Field( | |
| default=False, | |
| description="Greedy by default so the before/after differs only by the intervention", | |
| ) | |
| class PatchRequest(BaseModel): | |
| """ | |
| Activation-patching sweep between a clean/corrupted prompt pair. | |
| The prompts must tokenize to the same length. Answers must be single | |
| tokens (for GPT-2 that usually means a leading space, e.g. " Mary"). | |
| Each grid cell costs one full forward pass, so sweeps are capped at | |
| MAX_PATCH_RUNS cells — restrict layers/positions/heads to stay under it. | |
| """ | |
| clean_prompt: str | |
| corrupted_prompt: str | |
| clean_answer: str = Field(..., description="Single-token answer the clean prompt favors") | |
| corrupted_answer: str = Field(..., description="Single-token answer the corrupted prompt favors") | |
| direction: Literal["denoising", "noising"] = Field( | |
| default="denoising", | |
| description="denoising = clean acts into corrupted run (sufficiency); " | |
| "noising = corrupted acts into clean run (necessity)", | |
| ) | |
| component: Literal["resid_post", "head_z"] = Field( | |
| default="resid_post", | |
| description="resid_post sweeps layer x position; head_z sweeps layer x head", | |
| ) | |
| layers: Optional[List[int]] = Field(default=None, description="Default: all layers") | |
| positions: Optional[List[int]] = Field( | |
| default=None, description="resid_post only; negatives index from the end. Default: all" | |
| ) | |
| heads: Optional[List[int]] = Field(default=None, description="head_z only. Default: all heads") | |
| class RefusalBenchRequest(BaseModel): | |
| """ | |
| Run the Refusal Bench: a head-to-head comparison of refusal-ablation | |
| techniques on the loaded model, scored by refusal rate + harmfulness- | |
| probe AUC. | |
| techniques: subset of refusal_bench.techniques.TECHNIQUES keys | |
| (e.g. ["arditi", "cosmic", "cheng", "wollschlager"]). | |
| layer: residual-stream layer for extraction + ablation. | |
| harmful_prompts/harmless_prompts: contrastive pairs. The runner | |
| splits 80/20 (configurable) into extraction + eval folds. | |
| over_refusal_prompts: optional benign-but-edgy prompts (XSTest-style). | |
| Required by the "maskey" technique, which subtracts the | |
| over-refusal direction from the harmful direction; other | |
| techniques ignore it. | |
| """ | |
| technique_names: List[str] = Field(..., min_length=1) | |
| layer: int = Field(ge=0, le=27) | |
| harmful_prompts: List[str] = Field(..., min_length=5) | |
| harmless_prompts: List[str] = Field(..., min_length=5) | |
| over_refusal_prompts: Optional[List[str]] = Field(default=None, min_length=1) | |
| test_fraction: float = Field(default=0.2, gt=0.0, lt=1.0) | |
| max_new_tokens: int = Field(default=32, ge=1, le=128) | |
| temperature: float = Field(default=0.7, ge=0.0, le=2.0) | |
| seed: int = Field(default=42, ge=0) | |
| class HarmfulnessProbeRequest(BaseModel): | |
| """ | |
| Train (and optionally re-evaluate) a Zhao-style harmfulness probe. | |
| - layer: residual-stream layer used for both probe training and (if a | |
| direction is provided) ablation. | |
| - ablation_direction: optional. If set, the probe trained on baseline | |
| residuals is re-evaluated on residuals collected while this direction | |
| is projected out at `layer`. Mean p_harm staying high == residual | |
| stream still encodes harmfulness even after surface refusal collapses. | |
| """ | |
| harmful_prompts: List[str] = Field(..., min_length=2) | |
| harmless_prompts: List[str] = Field(..., min_length=2) | |
| layer: int = Field( | |
| ge=0, | |
| le=27, | |
| description="Layer for residual extraction (and ablation if direction given)", | |
| ) | |
| ablation_direction: Optional[List[float]] = None | |
| # ----------------------------------------------------------------------------- | |
| # Runtime validation | |
| # ----------------------------------------------------------------------------- | |
| def _validate_layer(layer: int) -> None: | |
| """Reject layer indices that exceed the loaded model's depth.""" | |
| m = model.get_model() | |
| if layer >= m.cfg.n_layers: | |
| raise HTTPException( | |
| status_code=422, | |
| detail=f"layer {layer} >= n_layers {m.cfg.n_layers}", | |
| ) | |
| def _validate_head(head: int) -> None: | |
| """Reject head indices that exceed the loaded model's n_heads.""" | |
| m = model.get_model() | |
| if head >= m.cfg.n_heads: | |
| raise HTTPException( | |
| status_code=422, | |
| detail=f"head {head} >= n_heads {m.cfg.n_heads}", | |
| ) | |
| # Public-deploy guards. The HF Space is unauthenticated, so restrict which | |
| # models can be pulled and cap the work any single request can trigger. | |
| ALLOWED_MODELS = { | |
| "gpt2-small", | |
| "meta-llama/Llama-3.2-1B-Instruct", | |
| "meta-llama/Llama-3.2-3B-Instruct", | |
| } | |
| MAX_PROMPT_CHARS = 2000 | |
| MAX_BENCH_PROMPTS = 200 | |
| # Each activation-patching cell is a full forward pass; 256 covers a complete | |
| # 12x12 GPT-2 head grid or a 12-layer x 21-position resid grid, while keeping | |
| # the worst-case request bounded on the free CPU tier. | |
| MAX_PATCH_RUNS = 256 | |
| def _validate_model_name(name: str) -> None: | |
| """Reject models outside the supported whitelist (no arbitrary HF pulls).""" | |
| if name not in ALLOWED_MODELS: | |
| raise HTTPException( | |
| status_code=422, | |
| detail=f"model '{name}' not allowed. Supported: {sorted(ALLOWED_MODELS)}", | |
| ) | |
| def _validate_prompt(prompt: str) -> None: | |
| """Bound prompt length so a single request can't pin the free CPU tier.""" | |
| if len(prompt) > MAX_PROMPT_CHARS: | |
| raise HTTPException( | |
| status_code=422, | |
| detail=f"prompt too long ({len(prompt)} chars > {MAX_PROMPT_CHARS})", | |
| ) | |
| # ----------------------------------------------------------------------------- | |
| # Endpoints | |
| # ----------------------------------------------------------------------------- | |
| async def health(): | |
| """Simple health check - useful for verifying the server is running.""" | |
| return {"status": "ok", "service": "neuroscope-api"} | |
| async def load_model(req: LoadRequest): | |
| """ | |
| Load a model into memory. Must be called before other endpoints. | |
| GPT-2 small (~500MB) takes a few seconds to load on first call. Llama | |
| models are gated and require HF_TOKEN in the environment. | |
| Subsequent calls with the same model return immediately. | |
| """ | |
| try: | |
| _validate_model_name(req.model_name) | |
| return model.load_model(req.model_name) | |
| except HTTPException: | |
| raise # preserve 422 from the whitelist check | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def logit_lens(req: PromptRequest): | |
| """ | |
| Run logit lens analysis on a prompt. | |
| Shows what the model would predict if we stopped at each layer. | |
| """ | |
| try: | |
| return research.logit_lens(req.prompt) | |
| except RuntimeError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| async def attention_pattern(req: AttentionRequest): | |
| """Get attention weights for a specific layer and head.""" | |
| try: | |
| _validate_layer(req.layer) | |
| _validate_head(req.head) | |
| return research.get_attention_pattern(req.prompt, req.layer, req.head) | |
| except RuntimeError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| async def token_gradients(req: GradientRequest): | |
| """Compute gradient-based token importance.""" | |
| try: | |
| return research.compute_token_gradients(req.prompt, req.target_token) | |
| except RuntimeError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| async def steering_vector(req: SteeringRequest): | |
| """Compute a steering vector from contrastive prompts (difference of means).""" | |
| try: | |
| _validate_layer(req.layer) | |
| return research.extract_steering_vector( | |
| req.positive_prompts, | |
| req.negative_prompts, | |
| req.layer, | |
| ) | |
| except RuntimeError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| async def contrastive_pairs(): | |
| """Get Moon's curated sentiment pairs (validated to tokenize to same length).""" | |
| pairs = research.get_contrastive_pairs() | |
| return { | |
| "pairs": [{"positive": p, "negative": n} for p, n in pairs], | |
| "count": len(pairs), | |
| } | |
| async def refusal_pairs(): | |
| """ | |
| Get curated refusal-direction contrastive pairs (harmful + harmless). | |
| Populate via `python backend/scripts/build_refusal_pairs.py` — pulls | |
| JailbreakBench + Alpaca, length-matches on Llama-3.2-1B tokenizer. | |
| Returns count=0 until populated. | |
| """ | |
| pairs = refusal_pairs_module.get_refusal_pairs() | |
| return { | |
| "pairs": [{"harmful": h, "harmless": s} for h, s in pairs], | |
| "count": len(pairs), | |
| } | |
| async def generate_steered(req: SteeredGenerationRequest): | |
| """Generate text with a steering vector injected at a specific layer.""" | |
| try: | |
| _validate_layer(req.layer) | |
| _validate_prompt(req.prompt) | |
| return research.generate_steered( | |
| req.prompt, | |
| req.steering_vector, | |
| req.alpha, | |
| req.layer, | |
| req.max_new_tokens, | |
| seed=req.seed, | |
| do_sample=req.do_sample, | |
| ) | |
| except RuntimeError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| async def ablate_direction(req: AblationRequest): | |
| """ | |
| Generate text with a direction projected out of the residual stream. | |
| Implements h' = h - (h · d̂) d̂ at the chosen layer. Standard primitive | |
| for testing claims like "direction d mediates behavior X" (Arditi 2024). | |
| Returns ablated and baseline generations for side-by-side comparison. | |
| """ | |
| try: | |
| _validate_layer(req.layer) | |
| _validate_prompt(req.prompt) | |
| return research.ablate_along_direction( | |
| req.prompt, | |
| req.direction, | |
| req.layer, | |
| req.max_new_tokens, | |
| seed=req.seed, | |
| do_sample=req.do_sample, | |
| ) | |
| except RuntimeError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| async def activation_patch(req: PatchRequest): | |
| """ | |
| Activation-patching sweep over (layer x position) or (layer x head). | |
| Runs the model on the base prompt while splicing in the source prompt's | |
| cached activation at one site per cell, measuring logit_diff(clean_answer | |
| - corrupted_answer) at the final position. `normalized` is 0 at the base | |
| run's own value and 1 at the source run's: in denoising that means full | |
| restoration (sufficiency), in noising full destruction (necessity). The | |
| two directions answer different questions and can disagree — neither | |
| alone identifies "the circuit". | |
| """ | |
| try: | |
| _validate_prompt(req.clean_prompt) | |
| _validate_prompt(req.corrupted_prompt) | |
| for layer in req.layers or []: | |
| _validate_layer(layer) | |
| return patching.run_activation_patching( | |
| req.clean_prompt, | |
| req.corrupted_prompt, | |
| req.clean_answer, | |
| req.corrupted_answer, | |
| direction=req.direction, | |
| component=req.component, | |
| layers=req.layers, | |
| positions=req.positions, | |
| heads=req.heads, | |
| max_runs=MAX_PATCH_RUNS, | |
| ) | |
| except (RuntimeError, ValueError) as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| async def harmfulness_probe(req: HarmfulnessProbeRequest): | |
| """ | |
| Train a linear harmfulness probe on residuals at `layer` (Zhao 2507.11878). | |
| Always returns the baseline (no-ablation) train/test AUCs and per-prompt | |
| P(harmful). If `ablation_direction` is supplied, also re-evaluates the | |
| probe on residuals collected while that direction is ablated at `layer`, | |
| returning the post-ablation P(harmful) on the harmful prompt set. | |
| The probe DOES NOT generalize across layers — call per layer for a sweep. | |
| """ | |
| try: | |
| _validate_layer(req.layer) | |
| harmful_resid = extract_last_token_residuals(req.harmful_prompts, req.layer) | |
| harmless_resid = extract_last_token_residuals(req.harmless_prompts, req.layer) | |
| train_result = train_probe(harmful_resid, harmless_resid) | |
| probe = train_result["model"] | |
| pre_eval = evaluate_probe( | |
| probe, | |
| harmful_resid, | |
| labels=[1] * harmful_resid.shape[0], | |
| ) | |
| response = { | |
| "layer": req.layer, | |
| "n_harmful": len(req.harmful_prompts), | |
| "n_harmless": len(req.harmless_prompts), | |
| "train_auc": train_result["train_auc"], | |
| "test_auc": train_result["test_auc"], | |
| "cv_auc_mean": train_result.get("cv_auc_mean"), | |
| "cv_auc_std": train_result.get("cv_auc_std"), | |
| "n_train": train_result["n_train"], | |
| "n_test": train_result["n_test"], | |
| "pre_ablation_p_harm": pre_eval["p_harm"], | |
| "pre_ablation_mean_p_harm": pre_eval["mean_p_harm"], | |
| "post_ablation_p_harm": None, | |
| "post_ablation_mean_p_harm": None, | |
| } | |
| if req.ablation_direction is not None: | |
| ablated_resid = extract_with_ablation( | |
| req.harmful_prompts, | |
| layer_extract=req.layer, | |
| ablation_direction=req.ablation_direction, | |
| ablation_layer=req.layer, | |
| ) | |
| post_eval = evaluate_probe( | |
| probe, | |
| ablated_resid, | |
| labels=[1] * ablated_resid.shape[0], | |
| ) | |
| response["post_ablation_p_harm"] = post_eval["p_harm"] | |
| response["post_ablation_mean_p_harm"] = post_eval["mean_p_harm"] | |
| return response | |
| except (RuntimeError, ValueError) as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| async def list_bench_techniques(): | |
| """List the refusal-ablation techniques registered in the bench.""" | |
| return { | |
| "techniques": [ | |
| { | |
| "key": key, | |
| "name": cls.name, | |
| "paper_url": cls.paper_url, | |
| } | |
| for key, cls in sorted(TECHNIQUES.items()) | |
| ], | |
| "count": len(TECHNIQUES), | |
| } | |
| async def refusal_bench(req: RefusalBenchRequest): | |
| """ | |
| Run the Refusal Bench end-to-end. | |
| Trains a shared harmfulness probe on the extraction split, then loops | |
| over every requested technique, fits it on the same extraction data, | |
| and scores it with refusal-rate (keyword-based) + probe AUC on the | |
| held-out eval split. Returns one row per technique. | |
| Two-axis story: a technique with high |Δ refusal rate| but low |Δ AUC| | |
| has suppressed verbal refusal without removing the model's internal | |
| harmfulness representation — the Zhao 2507.11878 dissociation, here | |
| measured across multiple ablation methods. | |
| """ | |
| try: | |
| _validate_layer(req.layer) | |
| over_refusal = req.over_refusal_prompts or [] | |
| total_prompts = ( | |
| len(req.harmful_prompts) + len(req.harmless_prompts) + len(over_refusal) | |
| ) | |
| if total_prompts > MAX_BENCH_PROMPTS: | |
| raise HTTPException( | |
| status_code=422, | |
| detail=f"too many prompts (> {MAX_BENCH_PROMPTS}); split the run", | |
| ) | |
| result = run_bench( | |
| technique_names=req.technique_names, | |
| layer=req.layer, | |
| harmful_prompts=req.harmful_prompts, | |
| harmless_prompts=req.harmless_prompts, | |
| over_refusal_prompts=req.over_refusal_prompts, | |
| test_fraction=req.test_fraction, | |
| max_new_tokens=req.max_new_tokens, | |
| temperature=req.temperature, | |
| seed=req.seed, | |
| ) | |
| return serialize_bench(result) | |
| except (RuntimeError, ValueError) as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| async def pca_trajectories(req: PromptRequest): | |
| """3D PCA coordinates for all tokens across all layers.""" | |
| try: | |
| return research.compute_pca_trajectories(req.prompt) | |
| except RuntimeError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |