neuroscope-api / main.py
lymnal's picture
sync: Wave 1+2+3 backend + 6 techniques + populated refusal/over-refusal data
ffb6dc9 verified
Raw
History Blame Contribute Delete
14.7 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, Optional
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
import model
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)
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)
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.
"""
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)
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}",
)
# -----------------------------------------------------------------------------
# Endpoints
# -----------------------------------------------------------------------------
@app.get("/")
async def health():
"""Simple health check - useful for verifying the server is running."""
return {"status": "ok", "service": "neuroscope-api"}
@app.post("/load")
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:
return model.load_model(req.model_name)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/logit-lens")
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))
@app.post("/attention")
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))
@app.post("/gradients")
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))
@app.post("/steering-vector")
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))
@app.get("/contrastive-pairs")
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),
}
@app.get("/refusal-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),
}
@app.post("/generate-steered")
async def generate_steered(req: SteeredGenerationRequest):
"""Generate text with a steering vector injected at a specific layer."""
try:
_validate_layer(req.layer)
return research.generate_steered(
req.prompt,
req.steering_vector,
req.alpha,
req.layer,
req.max_new_tokens,
)
except RuntimeError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.post("/ablate-direction")
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)
return research.ablate_along_direction(
req.prompt,
req.direction,
req.layer,
req.max_new_tokens,
)
except RuntimeError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.post("/harmfulness-probe")
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"],
"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))
@app.get("/refusal-bench/techniques")
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),
}
@app.post("/refusal-bench")
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)
result = run_bench(
technique_names=req.technique_names,
layer=req.layer,
harmful_prompts=req.harmful_prompts,
harmless_prompts=req.harmless_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))
@app.post("/pca-trajectories")
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))