Spaces:
Paused
Backend: add mask_positions intervention for What-if "Test this token"
Browse filesThe frontend's What-if lens "Test this token" mode posts
intervention_type=mask_positions with a list of arbitrary prompt
positions to mask, but the backend only ever implemented the three
contiguous-region masks (mask_system / mask_user_span /
mask_generated). Requests hit the "Unknown intervention type"
fallthrough.
Adds a mask_positions branch to /analyze/intervention:
- params.positions: list[int] (clamped to valid range, deduped)
- params.selected_token_id: int (optional; defaults to original
step's winner — the token whose logit delta we track)
- params.top_k: int (default 8, clamped 1–50)
Zeroes those positions in the attention mask, re-runs the forward
pass, returns the standard InterventionResponse with a rich details
dict matching the frontend's AblationResult interface: mask_type,
mask_positions_count, ablated_positions, seq_len, prompt_len,
selected_token_id / selected_token, selected_logit_original /
_ablated / _delta, and top_k_after_ablation with per-entry
{token_id, token, logit, probability}.
Verified locally: marking "implementation of" in a quicksort
prompt shifts the score by -0.375 and surfaces the top-6
post-ablation alternatives correctly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- backend/model_service.py +108 -1
|
@@ -4503,7 +4503,7 @@ async def get_attention_row(
|
|
| 4503 |
class InterventionRequest(BaseModel):
|
| 4504 |
request_id: str
|
| 4505 |
step: int
|
| 4506 |
-
intervention_type: str # "mask_system" | "mask_user_span" | "mask_generated" | "greedy" | "temperature_sweep" | "layer_ablation" | "head_ablation" | "expert_mask"
|
| 4507 |
params: dict = {}
|
| 4508 |
|
| 4509 |
class InterventionResponse(BaseModel):
|
|
@@ -4661,6 +4661,113 @@ async def run_intervention(request: InterventionRequest, authenticated: bool = D
|
|
| 4661 |
}
|
| 4662 |
)
|
| 4663 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4664 |
elif request.intervention_type == "layer_ablation":
|
| 4665 |
# Zero out a specific layer's contribution and recompute
|
| 4666 |
layer_idx = request.params.get("layer_idx", 0)
|
|
|
|
| 4503 |
class InterventionRequest(BaseModel):
|
| 4504 |
request_id: str
|
| 4505 |
step: int
|
| 4506 |
+
intervention_type: str # "mask_system" | "mask_user_span" | "mask_generated" | "mask_positions" | "greedy" | "temperature_sweep" | "layer_ablation" | "head_ablation" | "expert_mask"
|
| 4507 |
params: dict = {}
|
| 4508 |
|
| 4509 |
class InterventionResponse(BaseModel):
|
|
|
|
| 4661 |
}
|
| 4662 |
)
|
| 4663 |
|
| 4664 |
+
elif request.intervention_type == "mask_positions":
|
| 4665 |
+
# Per-position mask: the caller hands us an explicit list of
|
| 4666 |
+
# absolute token positions to zero out in the attention mask.
|
| 4667 |
+
# Used by the What-if lens's "Test this token" mode where the
|
| 4668 |
+
# user marks individual prompt tokens (not contiguous spans).
|
| 4669 |
+
#
|
| 4670 |
+
# Returns a richer details dict than the contiguous-region
|
| 4671 |
+
# mask handlers: top-K alternatives post-ablation, plus the
|
| 4672 |
+
# selected token's logit delta if the caller passed
|
| 4673 |
+
# selected_token_id. The frontend's AblationResult interface
|
| 4674 |
+
# expects all of these.
|
| 4675 |
+
positions = request.params.get("positions", []) or []
|
| 4676 |
+
if not isinstance(positions, list) or not all(isinstance(p, int) for p in positions):
|
| 4677 |
+
raise HTTPException(status_code=400, detail="mask_positions requires params.positions: list[int]")
|
| 4678 |
+
top_k = int(request.params.get("top_k", 8))
|
| 4679 |
+
top_k = max(1, min(top_k, 50))
|
| 4680 |
+
selected_token_id_param = request.params.get("selected_token_id")
|
| 4681 |
+
|
| 4682 |
+
cached_current_ids = hidden_state_cache.get_current_ids(request.request_id, request.step)
|
| 4683 |
+
input_ids_prompt = hidden_state_cache.get_input_ids(request.request_id)
|
| 4684 |
+
if cached_current_ids is None and input_ids_prompt is None:
|
| 4685 |
+
raise HTTPException(status_code=404, detail="Sequence data not available for this step. Please re-generate.")
|
| 4686 |
+
|
| 4687 |
+
if cached_current_ids is not None:
|
| 4688 |
+
full_ids = cached_current_ids.to(manager.device)
|
| 4689 |
+
else:
|
| 4690 |
+
full_ids = input_ids_prompt.to(manager.device)
|
| 4691 |
+
|
| 4692 |
+
seq_len = full_ids.shape[-1]
|
| 4693 |
+
prompt_len = input_ids_prompt.shape[-1] if input_ids_prompt is not None else seq_len
|
| 4694 |
+
|
| 4695 |
+
# Clamp to valid range and dedupe. Out-of-range positions are
|
| 4696 |
+
# silently dropped rather than raising — the UI may send a
|
| 4697 |
+
# stale set if the user navigated between steps with marks
|
| 4698 |
+
# still active.
|
| 4699 |
+
valid_positions = sorted({p for p in positions if 0 <= p < seq_len})
|
| 4700 |
+
|
| 4701 |
+
attention_mask = torch.ones(1, seq_len, dtype=torch.long, device=manager.device)
|
| 4702 |
+
for p in valid_positions:
|
| 4703 |
+
attention_mask[0, p] = 0
|
| 4704 |
+
|
| 4705 |
+
with torch.no_grad():
|
| 4706 |
+
masked_outputs = manager.model(
|
| 4707 |
+
full_ids,
|
| 4708 |
+
attention_mask=attention_mask,
|
| 4709 |
+
output_hidden_states=False,
|
| 4710 |
+
output_attentions=False,
|
| 4711 |
+
)
|
| 4712 |
+
recomputed_logits = masked_outputs.logits[0, -1, :]
|
| 4713 |
+
|
| 4714 |
+
top2_new, top2_new_ids = torch.topk(recomputed_logits, k=2)
|
| 4715 |
+
top2_new_list = top2_new.cpu().tolist()
|
| 4716 |
+
top2_new_ids_list = top2_new_ids.cpu().tolist()
|
| 4717 |
+
recomputed_margin = top2_new_list[0] - top2_new_list[1] if len(top2_new_list) >= 2 else 0.0
|
| 4718 |
+
recomputed_winner = manager.tokenizer.decode([top2_new_ids_list[0]], skip_special_tokens=False)
|
| 4719 |
+
|
| 4720 |
+
# The "selected token" is the one the user is studying — by
|
| 4721 |
+
# default the original winner at this step, but the caller
|
| 4722 |
+
# can override (e.g. to track the runner-up's behaviour).
|
| 4723 |
+
if isinstance(selected_token_id_param, int):
|
| 4724 |
+
selected_token_id = selected_token_id_param
|
| 4725 |
+
else:
|
| 4726 |
+
selected_token_id = top2_orig_ids_list[0]
|
| 4727 |
+
selected_token_text = manager.tokenizer.decode([selected_token_id], skip_special_tokens=False)
|
| 4728 |
+
selected_logit_original = float(raw_logits[selected_token_id].item())
|
| 4729 |
+
selected_logit_ablated = float(recomputed_logits[selected_token_id].item())
|
| 4730 |
+
selected_logit_delta = selected_logit_ablated - selected_logit_original
|
| 4731 |
+
|
| 4732 |
+
# Top-K alternatives after ablation, with both logits and
|
| 4733 |
+
# softmax probabilities so the frontend can render either.
|
| 4734 |
+
recomputed_probs = torch.softmax(recomputed_logits, dim=0)
|
| 4735 |
+
topk_vals, topk_ids = torch.topk(recomputed_logits, k=top_k)
|
| 4736 |
+
topk_ids_list = topk_ids.cpu().tolist()
|
| 4737 |
+
topk_vals_list = topk_vals.cpu().tolist()
|
| 4738 |
+
top_k_after_ablation = []
|
| 4739 |
+
for tid, logit_val in zip(topk_ids_list, topk_vals_list):
|
| 4740 |
+
top_k_after_ablation.append({
|
| 4741 |
+
"token_id": int(tid),
|
| 4742 |
+
"token": manager.tokenizer.decode([tid], skip_special_tokens=False),
|
| 4743 |
+
"logit": float(logit_val),
|
| 4744 |
+
"probability": float(recomputed_probs[tid].item()),
|
| 4745 |
+
})
|
| 4746 |
+
|
| 4747 |
+
return InterventionResponse(
|
| 4748 |
+
original_margin=original_margin,
|
| 4749 |
+
recomputed_margin=recomputed_margin,
|
| 4750 |
+
margin_shift=recomputed_margin - original_margin,
|
| 4751 |
+
original_stability=_classify_stability(original_margin),
|
| 4752 |
+
recomputed_stability=_classify_stability(recomputed_margin),
|
| 4753 |
+
original_winner=original_winner,
|
| 4754 |
+
recomputed_winner=recomputed_winner,
|
| 4755 |
+
winner_changed=top2_new_ids_list[0] != top2_orig_ids_list[0],
|
| 4756 |
+
details={
|
| 4757 |
+
"mask_type": "mask_positions",
|
| 4758 |
+
"mask_positions_count": len(valid_positions),
|
| 4759 |
+
"ablated_positions": valid_positions,
|
| 4760 |
+
"seq_len": seq_len,
|
| 4761 |
+
"prompt_len": prompt_len,
|
| 4762 |
+
"selected_token_id": int(selected_token_id),
|
| 4763 |
+
"selected_token": selected_token_text,
|
| 4764 |
+
"selected_logit_original": selected_logit_original,
|
| 4765 |
+
"selected_logit_ablated": selected_logit_ablated,
|
| 4766 |
+
"selected_logit_delta": selected_logit_delta,
|
| 4767 |
+
"top_k_after_ablation": top_k_after_ablation,
|
| 4768 |
+
}
|
| 4769 |
+
)
|
| 4770 |
+
|
| 4771 |
elif request.intervention_type == "layer_ablation":
|
| 4772 |
# Zero out a specific layer's contribution and recompute
|
| 4773 |
layer_idx = request.params.get("layer_idx", 0)
|