TokenTrace / backend /tests /test_ablation_attribute.py
cccmmd
init: TokenTrace - LLM interpretability toolbox
76b5743
Raw
History Blame Contribute Delete
10.4 kB
"""
消融归因单测。
分两类:
1. API handler 参数校验测试(不需要模型权重,mock core 函数)
2. 算法语义测试(需模型权重;@skipUnless 跳过)
运行: python -m unittest backend.tests.test_ablation_attribute
"""
from __future__ import annotations
import sys
import types
import unittest
from unittest.mock import MagicMock, patch
# ---------------------------------------------------------------------------
# Helpers:构造 mock 让 ablation_attribute handler 可在无模型环境下导入
# ---------------------------------------------------------------------------
def _make_mock_result(score=0.1, delta_logit=5.0):
return {
"model": "test-model",
"target_token": "北",
"target_prob": 0.8,
"token_attribution": [
{"offset": [0, 3], "raw": "中国", "score": score, "delta_logit": delta_logit, "delta_prob": 0.05},
],
"debug_info": {"topk_tokens": ["北"], "topk_probs": [0.8]},
"is_eos": False,
}
# ---------------------------------------------------------------------------
# Group 1: API handler 参数校验(mock core,无需模型)
# ---------------------------------------------------------------------------
class TestAblationAttributeHandlerValidation(unittest.TestCase):
"""参数校验:对应 spec 场景(缺字段 / 非法 model / 互斥目标等)"""
def _call(self, payload):
from backend.api.ablation_attribute import ablation_attribute
return ablation_attribute(payload)
def _patch_core(self):
return patch(
"backend.api.ablation_attribute.analyze_ablation_attribution",
return_value=_make_mock_result(),
)
def _patch_lock(self):
mock_lock = MagicMock()
mock_lock.acquire.return_value = True
return patch("backend.api.ablation_attribute.inference_lock", mock_lock)
def _patch_log(self):
return patch(
"backend.api.ablation_attribute.log_prediction_attribute_request",
return_value=1,
)
# --- 缺少必要字段 ---
def test_missing_context_returns_400(self):
resp, code = self._call({"model": "base", "source_page": "attribution"})
self.assertEqual(code, 400)
self.assertFalse(resp["success"])
def test_empty_context_returns_400(self):
resp, code = self._call({"context": "", "model": "base", "source_page": "attribution"})
self.assertEqual(code, 400)
self.assertFalse(resp["success"])
def test_missing_model_returns_400(self):
resp, code = self._call({"context": "hello", "source_page": "attribution"})
self.assertEqual(code, 400)
self.assertFalse(resp["success"])
def test_invalid_model_returns_400(self):
resp, code = self._call({"context": "hello", "model": "gpt4", "source_page": "attribution"})
self.assertEqual(code, 400)
self.assertIn("base", resp["message"])
def test_missing_source_page_returns_400(self):
resp, code = self._call({"context": "hello", "model": "base"})
self.assertEqual(code, 400)
self.assertFalse(resp["success"])
def test_invalid_source_page_returns_400(self):
resp, code = self._call({"context": "hello", "model": "base", "source_page": "bad_page"})
self.assertEqual(code, 400)
self.assertFalse(resp["success"])
# --- 目标互斥 ---
def test_mutually_exclusive_target_returns_400(self):
resp, code = self._call({
"context": "hello",
"model": "base",
"source_page": "attribution",
"target_prediction": "world",
"target_token_id": 5,
})
self.assertEqual(code, 400)
self.assertIn("mutually exclusive", resp["message"])
# --- 超长 context(core 抛 ValueError)---
def test_context_too_long_returns_400(self):
with self._patch_lock(), self._patch_log():
with patch(
"backend.api.ablation_attribute.analyze_ablation_attribution",
side_effect=ValueError("Context exceeds attribution length limit (500 tokens); current length is 600 tokens."),
):
resp, code = self._call({
"context": "x" * 2000,
"model": "base",
"source_page": "attribution",
})
self.assertEqual(code, 400)
self.assertIn("500", resp["message"])
# --- 正常 top-1 ---
def test_top1_default_returns_200(self):
with self._patch_core(), self._patch_lock(), self._patch_log():
resp, code = self._call({
"context": "中国的首都是",
"model": "base",
"source_page": "attribution",
})
self.assertEqual(code, 200)
self.assertTrue(resp["success"])
self.assertIn("token_attribution", resp)
# --- 显式 target_token_id ---
def test_explicit_target_token_id_returns_200(self):
with self._patch_core(), self._patch_lock(), self._patch_log():
resp, code = self._call({
"context": "中国的首都是",
"model": "base",
"source_page": "attribution",
"target_token_id": 1234,
})
self.assertEqual(code, 200)
self.assertTrue(resp["success"])
# --- 显式 target_prediction ---
def test_explicit_target_prediction_returns_200(self):
with self._patch_core(), self._patch_lock(), self._patch_log():
resp, code = self._call({
"context": "中国的首都是",
"model": "base",
"source_page": "attribution",
"target_prediction": "北京",
})
self.assertEqual(code, 200)
self.assertTrue(resp["success"])
# --- 服务繁忙 503 ---
def test_lock_timeout_returns_503(self):
with self._patch_log():
mock_lock = MagicMock()
mock_lock.acquire.return_value = False
with patch("backend.api.ablation_attribute.inference_lock", mock_lock):
resp, code = self._call({
"context": "hello",
"model": "base",
"source_page": "attribution",
})
self.assertEqual(code, 503)
self.assertFalse(resp["success"])
# ---------------------------------------------------------------------------
# Group 2: 算法语义(需模型)
# ---------------------------------------------------------------------------
def _model_available() -> bool:
try:
from backend.core.ablation_attributor import analyze_ablation_attribution
analyze_ablation_attribution("test", model="base")
return True
except Exception:
return False
@unittest.skipUnless(_model_available(), "base model weights not available")
class TestAblationAttributorSemantics(unittest.TestCase):
"""核心语义断言:score 公式、offset 合法、特殊 token 排除"""
@classmethod
def setUpClass(cls):
from backend.core.ablation_attributor import analyze_ablation_attribution
cls.result = analyze_ablation_attribution("中国的首都是", model="base")
def test_success_keys_present(self):
r = self.result
for key in ("model", "target_token", "target_prob", "token_attribution", "debug_info", "is_eos"):
self.assertIn(key, r)
def test_offsets_valid(self):
context = "中国的首都是"
for entry in self.result["token_attribution"]:
s, e = entry["offset"]
self.assertLess(s, e, "offset start must be < end")
self.assertEqual(entry["raw"], context[s:e], "raw must equal context[start:end]")
def test_score_sign_reasonable(self):
"""至少应存在非零 score(某 token 影响目标预测)"""
scores = [e["score"] for e in self.result["token_attribution"]]
self.assertTrue(any(abs(s) > 1e-10 for s in scores), "Expected at least one non-zero score")
def test_no_zero_width_tokens(self):
"""特殊 token(BOS/EOS 等,span 为空)不得出现在结果中"""
for entry in self.result["token_attribution"]:
s, e = entry["offset"]
self.assertGreater(e - s, 0)
def test_score_not_zero(self):
"""
使用 EOS 基线 + delta_logit 后,score 应为非零值(除非模型完全无反应)。
delta_logit 不受概率边界约束,因此对任意 target 均应产生有意义的分值。
"""
scores = [e["score"] for e in self.result["token_attribution"]]
non_zero = [s for s in scores if abs(s) > 1e-10]
self.assertGreater(len(non_zero), 0, "Expected at least one non-zero delta_logit score")
def test_target_token_id_explicit(self):
"""显式 target_token_id 应返回对应 target_token"""
from backend.core.ablation_attributor import analyze_ablation_attribution
from backend.models.model_manager import ModelSlot, ensure_slot_weights_loaded
tokenizer, _, _ = ensure_slot_weights_loaded(ModelSlot.BASE)
# 用 top-1 结果的 token_id
tok_id = tokenizer.encode(self.result["target_token"], add_special_tokens=False)[0]
r2 = analyze_ablation_attribution("中国的首都是", model="base", target_token_id=tok_id)
self.assertEqual(r2["target_token"], self.result["target_token"])
def test_mutually_exclusive_raises(self):
from backend.core.ablation_attributor import analyze_ablation_attribution
with self.assertRaises(ValueError):
analyze_ablation_attribution(
"hello",
target_prediction="world",
model="base",
target_token_id=5,
)
def test_too_long_raises(self):
from backend.core.ablation_attributor import (
analyze_ablation_attribution,
ATTRIBUTION_MAX_TOKEN_LENGTH,
)
long_ctx = "hello world " * (ATTRIBUTION_MAX_TOKEN_LENGTH + 10)
with self.assertRaises(ValueError) as ctx:
analyze_ablation_attribution(long_ctx, model="base")
self.assertIn(str(ATTRIBUTION_MAX_TOKEN_LENGTH), str(ctx.exception))
if __name__ == "__main__":
unittest.main()