Buckets:
| #!/usr/bin/env python3 | |
| # -*- coding: utf-8 -*- | |
| """ | |
| Table-4 Counterfactual Capability Sensitivity (offline eval) | |
| Pipeline per query Q: | |
| 1) TwoTower retriever: | |
| - LLM shortlist: topK_llm from global LLMs/merge.json (via best agent score) | |
| - Tool pool: topK_tool_pool from global Tools/merge.json (via best agent score, using tool_query) | |
| 2) Build "Most idealized" agent A_full: | |
| - Default: GPT picks backbone + tools (can pick from large tool pool, not limited to top10) | |
| - Fallback (no OPENAI_API_KEY): heuristic = top1 LLM + top3 tools | |
| 3) Counterfactual interventions (single controlled change): | |
| - Remove key tool | |
| - Remove secondary tool | |
| - Add irrelevant tool | |
| - Add redundant tool | |
| - Swap backbone (rank 2–5 from LLM shortlist) | |
| 4) Call external scoring API once per query with 1+5 docs: | |
| POST { "query": str, "documents": [str, ...] } -> returns {"scores":[...]} or similar | |
| 5) Aggregate: | |
| Δs = s(Q, A_full) - s(Q, A_cf) | |
| Δr = rank(A_cf) - rank(A_full) within the 6-item set (A_full + 5 counterfactuals) | |
| Consistency = I[s_full > s_cf] | |
| Output LaTeX rows to fill Table 4. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import os | |
| import random | |
| import urllib.error | |
| import urllib.request | |
| from dataclasses import dataclass | |
| from typing import Dict, Iterable, List, Tuple, Optional | |
| import numpy as np | |
| import torch | |
| from tqdm.auto import tqdm | |
| from agent_rec.config import TFIDF_MAX_FEATURES | |
| from agent_rec.features import ( | |
| UNK_LLM_TOKEN, | |
| UNK_TOOL_TOKEN, | |
| build_agent_content_view, | |
| feature_cache_exists, | |
| load_feature_cache, | |
| load_vectorizers, | |
| ) | |
| from agent_rec.models.two_tower import TwoTowerTFIDF | |
| from agent_rec.run_common import bootstrap_run, shared_cache_dir | |
| from agent_rec.data import load_tools as load_tools_json, load_LLMs as load_llms_json | |
| # ---------------------------- | |
| # External Scoring API | |
| # ---------------------------- | |
| def _http_post_json(url: str, payload: dict, timeout_s: float = 30.0) -> object: | |
| data = json.dumps(payload, ensure_ascii=False).encode("utf-8") | |
| req = urllib.request.Request( | |
| url=url, | |
| data=data, | |
| headers={"Content-Type": "application/json"}, | |
| method="POST", | |
| ) | |
| try: | |
| with urllib.request.urlopen(req, timeout=timeout_s) as resp: | |
| body = resp.read().decode("utf-8", errors="replace") | |
| except urllib.error.HTTPError as e: | |
| err_body = "" | |
| try: | |
| err_body = e.read().decode("utf-8", errors="replace") | |
| except Exception: | |
| pass | |
| raise RuntimeError(f"Scoring API HTTPError {e.code}: {err_body or str(e)}") | |
| except Exception as e: | |
| raise RuntimeError(f"Scoring API request failed: {e}") | |
| try: | |
| return json.loads(body) | |
| except Exception: | |
| return body | |
| def _extract_scores(api_resp: object, n_docs: int) -> List[float]: | |
| scores: List[float] = [] | |
| if isinstance(api_resp, dict): | |
| for k in ("scores", "similarities", "logits"): | |
| v = api_resp.get(k) | |
| if isinstance(v, list): | |
| scores = [float(x) for x in v] | |
| break | |
| if not scores and isinstance(api_resp.get("data"), dict): | |
| data = api_resp["data"] | |
| for k in ("scores", "similarities", "logits"): | |
| v = data.get(k) | |
| if isinstance(v, list): | |
| scores = [float(x) for x in v] | |
| break | |
| if not scores and isinstance(api_resp.get("results"), list): | |
| tmp = [] | |
| for it in api_resp["results"]: | |
| if isinstance(it, dict) and "score" in it: | |
| tmp.append(float(it["score"])) | |
| if tmp: | |
| scores = tmp | |
| elif isinstance(api_resp, list): | |
| if api_resp and all(isinstance(x, (int, float)) for x in api_resp): | |
| scores = [float(x) for x in api_resp] | |
| elif api_resp and all(isinstance(x, dict) for x in api_resp): | |
| tmp = [] | |
| for it in api_resp: | |
| if "score" in it: | |
| tmp.append(float(it["score"])) | |
| if tmp: | |
| scores = tmp | |
| if not scores: | |
| raise RuntimeError(f"Scoring API response has no parsable scores. resp={str(api_resp)[:500]}") | |
| if len(scores) < n_docs: | |
| scores = scores + ([-1e9] * (n_docs - len(scores))) | |
| elif len(scores) > n_docs: | |
| scores = scores[:n_docs] | |
| return scores | |
| # ---------------------------- | |
| # OpenAI (optional) | |
| # ---------------------------- | |
| IDEAL_AGENT_PROMPT = """You are designing an IDEAL agent configuration for a user query. | |
| Goal: choose the backbone LLM and a toolset that best matches the query intent, even if some tools are NOT in the top-10 retrieved list. | |
| Return ONLY valid JSON (no markdown), with keys: | |
| - A_full: {{ "M": {{"name": str}}, "T": {{"tools": [str, ...]}} }} | |
| - key_tool: str (most query-critical tool; must be in A_full.T.tools if possible) | |
| - secondary_tool: str (second most important; in tools if possible; can be empty) | |
| - irrelevant_tool: str (a tool to add that is likely irrelevant / distracting; can be outside pool) | |
| - redundant_tool: str (a tool to add that is likely redundant / clutter; can be outside pool) | |
| User query: | |
| {query} | |
| LLM shortlist (ranked, with descriptions): | |
| {llm_list_json} | |
| Tool pool (ranked, with descriptions): | |
| {tool_pool_json} | |
| Constraints: | |
| - Prefer selecting tools from Tool pool; if none fits, you may propose a tool name not in pool. Because some functionalities may be missing from the pool. When proposing new tools, use prefix "CustomTool_" to indicate they are not from the pool but need to implementation. | |
| - Keep tools list concise (typically 3-5 tools). | |
| """ | |
| # ---------------------------- | |
| # GPT Tool-Query Rewriting | |
| # ---------------------------- | |
| TOOL_QUERY_PROMPT = """You are a search query rewriter for TOOL retrieval in an agent recommender. | |
| Given a user's natural-language request, rewrite it into a compact "tool search query" that helps match tools. | |
| Rules: | |
| - Output ONLY JSON (no markdown fences). | |
| - Keys: tool_query, rationale | |
| - tool_query should be <= 32 tokens, English preferred, include concrete actions/APIs (e.g., "weather forecast", "currency exchange rate", "send email", "calendar event create"). | |
| - Do NOT include model names. Focus on tools / APIs / operations. | |
| - If user request contains multiple intents, keep the top 2-3 tool intents, separated by "; ". | |
| - Keep important constraints (location, format, source) if present. | |
| User query: | |
| {query} | |
| """ | |
| def _try_import_openai(): | |
| try: | |
| import openai # type: ignore | |
| return openai | |
| except Exception: | |
| return None | |
| def _gpt_chat(prompt: str, model_name: str, temperature: float = 0.0) -> str: | |
| api_key = os.environ.get("OPENAI_API_KEY", "").strip() | |
| if not api_key: | |
| raise RuntimeError("OPENAI_API_KEY is not set.") | |
| openai = _try_import_openai() | |
| if openai is None: | |
| raise RuntimeError("openai python package not found. Please `pip install openai`.") | |
| client = openai.OpenAI(api_key=api_key) | |
| resp = client.chat.completions.create( | |
| model=model_name, | |
| messages=[{"role": "user", "content": prompt}], | |
| n=1, | |
| stream=False, | |
| service_tier="default", | |
| ) | |
| return resp.choices[0].message.content | |
| def rewrite_tool_query_gpt(query: str, model_name: str) -> str: | |
| prompt = TOOL_QUERY_PROMPT.format(query=query) | |
| raw = _gpt_chat(prompt, model_name=model_name, temperature=0.0) | |
| try: | |
| obj = json.loads(raw) | |
| tq = str(obj.get("tool_query", "")).strip() | |
| return tq if tq else query | |
| except Exception: | |
| return query | |
| class AgentCfg: | |
| llm_name: str | |
| tools: List[str] | |
| # ---------------------------- | |
| # TwoTower inference backbone | |
| # ---------------------------- | |
| def _device_from_arg(device_str: str) -> torch.device: | |
| device = torch.device(device_str) | |
| if device.type.startswith("cuda") and not torch.cuda.is_available(): | |
| print(f"[warn] CUDA 不可用,回退到 CPU (请求: {device_str}).") | |
| return torch.device("cpu") | |
| return device | |
| def _load_checkpoint(model_path: str, device: torch.device) -> dict: | |
| if not os.path.exists(model_path): | |
| raise FileNotFoundError(f"找不到模型文件: {model_path}") | |
| ckpt = torch.load(model_path, map_location=device) | |
| if not isinstance(ckpt, dict) or "state_dict" not in ckpt: | |
| raise RuntimeError(f"模型文件不合法: {model_path}") | |
| return ckpt | |
| def _resolve_feature_cache_dir(data_root: str, max_features: int, data_sig: str) -> str: | |
| return shared_cache_dir(data_root, "features", f"twotower_tfidf_{max_features}_{data_sig}") | |
| def _build_encoder(*, ckpt: dict, feature_cache, device: torch.device) -> TwoTowerTFIDF: | |
| flags = ckpt.get("flags", {}) if isinstance(ckpt, dict) else {} | |
| dims = ckpt.get("dims", {}) if isinstance(ckpt, dict) else {} | |
| encoder = TwoTowerTFIDF( | |
| d_q=int(dims.get("d_q", feature_cache.Q.shape[1])), | |
| d_a=int( | |
| dims.get( | |
| "d_a", | |
| feature_cache.A_text_full.shape[1] | |
| if hasattr(feature_cache, "A_text_full") | |
| else feature_cache.A_model_content.shape[1], | |
| ) | |
| ), | |
| hid=int(dims.get("hid", 256)), | |
| num_tools=int(dims.get("num_tools", len(feature_cache.tool_id_vocab))), | |
| num_llm_ids=int(len(feature_cache.llm_vocab)), | |
| agent_tool_idx_padded=torch.tensor(feature_cache.agent_tool_idx_padded, dtype=torch.long, device=device), | |
| agent_tool_mask=torch.tensor(feature_cache.agent_tool_mask, dtype=torch.float32, device=device), | |
| agent_llm_idx=torch.tensor(feature_cache.agent_llm_idx, dtype=torch.long, device=device), | |
| use_tool_id_emb=bool(flags.get("use_tool_id_emb", True)), | |
| use_llm_id_emb=bool(flags.get("use_llm_id_emb", False)), | |
| num_agents=len(feature_cache.a_ids), | |
| num_queries=len(feature_cache.q_ids), | |
| use_query_id_emb=bool(flags.get("use_query_id_emb", False)), | |
| ).to(device) | |
| encoder.load_state_dict(ckpt["state_dict"], strict=False) | |
| encoder.eval() | |
| return encoder | |
| class TwoTowerRetriever: | |
| """ | |
| Minimal retriever: | |
| - score agents by dot(q_emb, agent_emb) | |
| - lift to LLM/tool candidates by max score over agents containing that component | |
| """ | |
| def __init__(self, *, data_root: str, model_path: str, device: torch.device, max_features: int) -> None: | |
| self.data_root = data_root | |
| self.device = device | |
| self.max_features = max_features | |
| boot = bootstrap_run( | |
| data_root=data_root, | |
| exp_name="eval_table4_cf", | |
| topk=10, | |
| seed=1234, | |
| with_tools=True, | |
| ) | |
| self.bundle = boot.bundle | |
| ckpt = _load_checkpoint(model_path, device) | |
| self.ckpt = ckpt | |
| self.data_sig = ckpt.get("data_sig", boot.data_sig) | |
| cache_dir = _resolve_feature_cache_dir(data_root, max_features, self.data_sig) | |
| if not feature_cache_exists(cache_dir): | |
| raise RuntimeError( | |
| f"未找到特征缓存: {cache_dir}\n" | |
| "请确认使用相同 data_root/max_features 训练过 TwoTower TF-IDF 并生成了 feature cache。" | |
| ) | |
| self.feature_cache = load_feature_cache(cache_dir) | |
| vecs = load_vectorizers(cache_dir) | |
| if vecs is None or not hasattr(vecs, "q_vec"): | |
| raise RuntimeError(f"未找到 TF-IDF q_vectorizer: {cache_dir}") | |
| self.q_vectorizer = vecs.q_vec | |
| flags = ckpt.get("flags", {}) if isinstance(ckpt, dict) else {} | |
| self.use_query_id_emb = bool(flags.get("use_query_id_emb", False)) | |
| use_model_content_vector = bool(flags.get("use_model_content_vector", True)) | |
| use_tool_content_vector = bool(flags.get("use_tool_content_vector", True)) | |
| self.agent_content = build_agent_content_view( | |
| cache=self.feature_cache, | |
| use_model_content_vector=use_model_content_vector, | |
| use_tool_content_vector=use_tool_content_vector, | |
| ) | |
| self.encoder = _build_encoder(ckpt=ckpt, feature_cache=self.feature_cache, device=device) | |
| self.encoder.set_agent_features(self.agent_content) | |
| self.agent_embeddings = self.encoder.export_agent_embeddings() # (N_agents, d) | |
| self.agent_ids = list(self.feature_cache.a_ids) | |
| # parse agent components | |
| self.agent_tools: List[List[str]] = [] | |
| self.agent_llm_names: List[str] = [] | |
| for aid in self.agent_ids: | |
| agent = self.bundle.all_agents.get(aid, {}) or {} | |
| m = (agent.get("M") or {}) if isinstance(agent, dict) else {} | |
| t = (agent.get("T") or {}) if isinstance(agent, dict) else {} | |
| self.agent_llm_names.append((m.get("name") or m.get("id") or "").strip()) | |
| self.agent_tools.append(list((t.get("tools") or []))) | |
| # global vocab | |
| llm_json = load_llms_json(data_root) or {} | |
| tool_json = load_tools_json(data_root) or {} | |
| self.llm_candidates: List[str] = [k for k in llm_json.keys() if k and k != UNK_LLM_TOKEN] | |
| self.tool_candidates: List[str] = [k for k in tool_json.keys() if k and k != UNK_TOOL_TOKEN] | |
| self.llm_desc_map: Dict[str, str] = {k: str((v or {}).get("description", "")).strip() for k, v in llm_json.items() if k} | |
| self.tool_desc_map: Dict[str, str] = {k: str((v or {}).get("description", "")).strip() for k, v in tool_json.items() if k} | |
| # reverse indices: component -> agent indices | |
| self._llm_to_agent_indices: Dict[str, List[int]] = {} | |
| self._tool_to_agent_indices: Dict[str, List[int]] = {} | |
| for i in range(len(self.agent_ids)): | |
| ln = (self.agent_llm_names[i] or "").strip().lower() | |
| if ln: | |
| self._llm_to_agent_indices.setdefault(ln, []).append(i) | |
| for tool in self.agent_tools[i]: | |
| if tool: | |
| self._tool_to_agent_indices.setdefault(tool, []).append(i) | |
| def _encode_query(self, text: str) -> np.ndarray: | |
| vec = self.q_vectorizer.transform([text]).toarray().astype(np.float32) | |
| q = torch.from_numpy(vec).to(self.device) | |
| q_idx = torch.zeros(1, dtype=torch.long, device=self.device) if self.use_query_id_emb else None | |
| with torch.no_grad(): | |
| qe = self.encoder.encode_q(q, q_idx=q_idx).cpu().numpy() # (1, d) | |
| return qe | |
| def score_all_agents(self, text: str) -> np.ndarray: | |
| qe = self._encode_query(text) | |
| return np.dot(qe, self.agent_embeddings.T).reshape(-1) # (N_agents,) | |
| def rank_llms(self, agent_scores: np.ndarray, topk: int) -> List[Dict[str, object]]: | |
| out = [] | |
| for llm in self.llm_candidates: | |
| idxs = self._llm_to_agent_indices.get(llm.strip().lower(), []) | |
| if not idxs: | |
| continue | |
| best_i = max(idxs, key=lambda i: float(agent_scores[i])) | |
| out.append({"name": llm, "score": float(agent_scores[best_i]), "description": self.llm_desc_map.get(llm, "")}) | |
| out.sort(key=lambda x: -float(x["score"])) | |
| return out[:topk] | |
| def rank_tools(self, agent_scores: np.ndarray, topk: int) -> List[Dict[str, object]]: | |
| out = [] | |
| for tool in self.tool_candidates: | |
| idxs = self._tool_to_agent_indices.get(tool, []) | |
| if not idxs: | |
| continue | |
| best_i = max(idxs, key=lambda i: float(agent_scores[i])) | |
| out.append({"name": tool, "score": float(agent_scores[best_i]), "description": self.tool_desc_map.get(tool, "")}) | |
| out.sort(key=lambda x: -float(x["score"])) | |
| return out[:topk] | |
| def pick_low_rank_tool(self, agent_scores: np.ndarray, avoid: set, tail_k: int = 200) -> Optional[str]: | |
| """ | |
| Choose a low-ranked tool as "irrelevant" fallback. | |
| """ | |
| ranked = self.rank_tools(agent_scores, topk=min(tail_k, max(50, tail_k))) | |
| if not ranked: | |
| return None | |
| # take from the tail of this list | |
| tail = list(reversed(ranked)) | |
| for it in tail: | |
| name = it["name"] | |
| if name not in avoid: | |
| return name | |
| return None | |
| def tool_desc(self, name: str) -> str: | |
| return (self.tool_desc_map.get(name, "") or "").strip() | |
| def llm_desc(self, name: str) -> str: | |
| return (self.llm_desc_map.get(name, "") or "").strip() | |
| # ---------------------------- | |
| # Doc building for scoring API | |
| # ---------------------------- | |
| def agent_to_document(agent: AgentCfg, llm_desc: str, tool_desc_map: Dict[str, str]) -> str: | |
| tools = [t for t in agent.tools if t] | |
| parts = [f"Backbone LLM: {agent.llm_name}"] | |
| if llm_desc: | |
| parts.append(f"LLM description: {llm_desc}") | |
| if tools: | |
| parts.append("Tools:") | |
| for t in tools: | |
| td = (tool_desc_map.get(t, "") or "").strip() | |
| parts.append(f"- {t}: {td}" if td else f"- {t}") | |
| else: | |
| parts.append("Tools: (none)") | |
| return "\n".join(parts).strip() | |
| # ---------------------------- | |
| # Ideal agent generation | |
| # ---------------------------- | |
| def parse_ideal_agent(raw: str) -> Tuple[Optional[AgentCfg], dict]: | |
| try: | |
| obj = json.loads(raw) | |
| except Exception: | |
| return None, {"error": "json_parse_failed", "raw": raw[:500]} | |
| af = obj.get("A_full") or {} | |
| m = (af.get("M") or {}) if isinstance(af, dict) else {} | |
| t = (af.get("T") or {}) if isinstance(af, dict) else {} | |
| llm = (m.get("name") or "").strip() if isinstance(m, dict) else "" | |
| tools = (t.get("tools") or []) if isinstance(t, dict) else [] | |
| if not isinstance(tools, list): | |
| tools = [] | |
| tools = [str(x).strip() for x in tools if str(x).strip()] | |
| if not llm: | |
| return None, {"error": "missing_llm", "obj": obj} | |
| meta = { | |
| "key_tool": str(obj.get("key_tool", "") or "").strip(), | |
| "secondary_tool": str(obj.get("secondary_tool", "") or "").strip(), | |
| "irrelevant_tool": str(obj.get("irrelevant_tool", "") or "").strip(), | |
| "redundant_tool": str(obj.get("redundant_tool", "") or "").strip(), | |
| "obj": obj, | |
| } | |
| return AgentCfg(llm_name=llm, tools=tools), meta | |
| def build_ideal_agent( | |
| query: str, | |
| llm_list: List[Dict[str, object]], | |
| tool_pool: List[Dict[str, object]], | |
| *, | |
| use_gpt: bool, | |
| gpt_model: str, | |
| ) -> Tuple[AgentCfg, dict, Optional[str]]: | |
| """ | |
| Returns (A_full, meta, gpt_error) | |
| meta includes key_tool/secondary_tool/irrelevant_tool/redundant_tool. | |
| """ | |
| if use_gpt: | |
| prompt = IDEAL_AGENT_PROMPT.format( | |
| query=query, | |
| llm_list_json=json.dumps(llm_list, ensure_ascii=False, indent=2), | |
| tool_pool_json=json.dumps(tool_pool, ensure_ascii=False, indent=2), | |
| ) | |
| try: | |
| raw = _gpt_chat(prompt, model_name=gpt_model, temperature=0.0) | |
| agent, meta = parse_ideal_agent(raw) | |
| if agent is not None: | |
| return agent, meta, None | |
| return heuristic_ideal_agent(llm_list, tool_pool), meta, "GPT returned invalid JSON; fallback to heuristic." | |
| except Exception as e: | |
| return heuristic_ideal_agent(llm_list, tool_pool), {}, f"GPT failed; fallback to heuristic. err={e}" | |
| return heuristic_ideal_agent(llm_list, tool_pool), {}, None | |
| def heuristic_ideal_agent(llm_list: List[Dict[str, object]], tool_pool: List[Dict[str, object]]) -> AgentCfg: | |
| llm = (llm_list[0]["name"] if llm_list else "UNKNOWN_LLM") | |
| tools = [] | |
| for it in tool_pool[:3]: | |
| tools.append(str(it["name"])) | |
| return AgentCfg(llm_name=str(llm), tools=tools) | |
| # ---------------------------- | |
| # Counterfactual construction | |
| # ---------------------------- | |
| def unique_tools(tools: Iterable[str]) -> List[str]: | |
| seen = set() | |
| out = [] | |
| for t in tools: | |
| t = (t or "").strip() | |
| if t and t not in seen: | |
| seen.add(t) | |
| out.append(t) | |
| return out | |
| def remove_tool(agent: AgentCfg, tool_name: str) -> AgentCfg: | |
| tools = [t for t in agent.tools if t != tool_name] | |
| return AgentCfg(llm_name=agent.llm_name, tools=tools) | |
| def add_tool(agent: AgentCfg, tool_name: str) -> AgentCfg: | |
| tools = unique_tools(agent.tools + [tool_name]) | |
| return AgentCfg(llm_name=agent.llm_name, tools=tools) | |
| def swap_llm(agent: AgentCfg, new_llm: str) -> AgentCfg: | |
| return AgentCfg(llm_name=new_llm, tools=list(agent.tools)) | |
| # ---------------------------- | |
| # Metrics aggregation | |
| # ---------------------------- | |
| class Agg: | |
| ds: List[float] | |
| dr: List[float] | |
| ok: List[int] # consistency 0/1 | |
| def add(self, ds: float, dr: float, ok: int) -> None: | |
| self.ds.append(float(ds)) | |
| self.dr.append(float(dr)) | |
| self.ok.append(int(ok)) | |
| def summary(self) -> Tuple[float, float, float]: | |
| m_ds = float(np.mean(self.ds)) if self.ds else 0.0 | |
| m_dr = float(np.mean(self.dr)) if self.dr else 0.0 | |
| m_ok = float(np.mean(self.ok)) * 100.0 if self.ok else 0.0 | |
| return m_ds, m_dr, m_ok | |
| # ---------------------------- | |
| # Load jsonl | |
| # ---------------------------- | |
| def load_questions(jsonl_path: str) -> List[str]: | |
| qs = [] | |
| with open(jsonl_path, "r", encoding="utf-8") as f: | |
| for line in f: | |
| line = line.strip() | |
| if not line: | |
| continue | |
| try: | |
| obj = json.loads(line) | |
| except Exception: | |
| continue | |
| q = (obj.get("question") or "").strip() | |
| if q: | |
| qs.append(q) | |
| return qs | |
| # ---------------------------- | |
| # Main | |
| # ---------------------------- | |
| def parse_args() -> argparse.Namespace: | |
| ap = argparse.ArgumentParser("Table-4 counterfactual capability sensitivity") | |
| ap.add_argument("--data_root", type=str, required=True) | |
| ap.add_argument("--model_path", type=str, required=True) | |
| ap.add_argument("--questions_jsonl", type=str, required=True) | |
| ap.add_argument("--scoring_url", type=str, default="http://127.0.0.1:8501/compute_scores") | |
| ap.add_argument("--device", type=str, default="cpu") | |
| ap.add_argument("--max_features", type=int, default=TFIDF_MAX_FEATURES) | |
| ap.add_argument("--N", type=int, default=100, help="number of queries sampled") | |
| ap.add_argument("--seed", type=int, default=1234) | |
| ap.add_argument("--topk_llm", type=int, default=10) | |
| ap.add_argument("--topk_tool_pool", type=int, default=20) | |
| ap.add_argument("--use_gpt", action="store_true", help="use GPT to build ideal A_full (needs OPENAI_API_KEY)") | |
| ap.add_argument("--gpt_model", type=str, default="gpt-5") | |
| ap.add_argument("--use_gpt_tool_query", action="store_true", help="use GPT tool-query rewriting (optional)") | |
| return ap.parse_args() | |
| def main() -> None: | |
| args = parse_args() | |
| random.seed(args.seed) | |
| np.random.seed(args.seed) | |
| device = _device_from_arg(args.device) | |
| retriever = TwoTowerRetriever( | |
| data_root=args.data_root, | |
| model_path=args.model_path, | |
| device=device, | |
| max_features=args.max_features, | |
| ) | |
| questions = load_questions(args.questions_jsonl) | |
| if not questions: | |
| raise RuntimeError(f"No valid 'question' found in {args.questions_jsonl}") | |
| if len(questions) >= args.N: | |
| sampled = random.sample(questions, args.N) | |
| else: | |
| sampled = questions | |
| aggs = { | |
| "remove_key": Agg([], [], []), | |
| "remove_secondary": Agg([], [], []), | |
| "add_irrelevant": Agg([], [], []), | |
| "add_redundant": Agg([], [], []), | |
| "swap_backbone": Agg([], [], []), | |
| } | |
| gpt_fail_cnt = 0 | |
| for q in tqdm(sampled, desc="Table4 counterfactual", total=len(sampled)): | |
| # (1) LLM shortlist uses original query | |
| agent_scores = retriever.score_all_agents(q) | |
| llm_list = retriever.rank_llms(agent_scores, topk=args.topk_llm) | |
| # (2) Tool pool uses tool_query (optionally rewritten) | |
| tool_query = q | |
| if args.use_gpt_tool_query and args.use_gpt: | |
| try: | |
| tool_query = rewrite_tool_query_gpt(q, model_name=args.gpt_model) | |
| except Exception: | |
| tool_query = q | |
| tool_agent_scores = retriever.score_all_agents(tool_query) | |
| tool_pool = retriever.rank_tools(tool_agent_scores, topk=args.topk_tool_pool) | |
| # (3) Build A_full | |
| A_full, meta, gpt_err = build_ideal_agent( | |
| q, llm_list, tool_pool, use_gpt=args.use_gpt, gpt_model=args.gpt_model | |
| ) | |
| print("Ideal agent for query:\n",A_full) | |
| if gpt_err: | |
| gpt_fail_cnt += 1 | |
| A_full.tools = unique_tools(A_full.tools) | |
| # determine key/secondary | |
| key_tool = (meta.get("key_tool") or "").strip() | |
| secondary_tool = (meta.get("secondary_tool") or "").strip() | |
| if not key_tool and A_full.tools: | |
| key_tool = A_full.tools[0] | |
| if (not secondary_tool or secondary_tool == key_tool) and len(A_full.tools) >= 2: | |
| secondary_tool = A_full.tools[1] | |
| avoid = set(A_full.tools) | |
| # irrelevant / redundant tool picks | |
| irr_tool = (meta.get("irrelevant_tool") or "").strip() | |
| red_tool = (meta.get("redundant_tool") or "").strip() | |
| if not irr_tool: | |
| irr_tool = retriever.pick_low_rank_tool(tool_agent_scores, avoid=avoid) or "IrrelevantTool" | |
| if not red_tool: | |
| # fallback: pick a high-ranked tool not already included; else duplicate key_tool | |
| red_tool = "" | |
| for it in tool_pool[:20]: | |
| cand = str(it["name"]) | |
| if cand and cand not in avoid: | |
| red_tool = cand | |
| break | |
| if not red_tool: | |
| red_tool = key_tool or "RedundantTool" | |
| # swap backbone: choose rank 2-5 | |
| swap_llm_name = "" | |
| for it in llm_list[1:5]: | |
| cand = str(it["name"]).strip() | |
| if cand and cand.lower() != A_full.llm_name.strip().lower(): | |
| swap_llm_name = cand | |
| break | |
| # (4) Build counterfactual variants | |
| cf_remove_key = remove_tool(A_full, key_tool) if key_tool else A_full | |
| cf_remove_secondary = remove_tool(A_full, secondary_tool) if secondary_tool else A_full | |
| cf_add_irrelevant = add_tool(A_full, irr_tool) if irr_tool else A_full | |
| cf_add_redundant = add_tool(A_full, red_tool) if red_tool else A_full | |
| cf_swap = swap_llm(A_full, swap_llm_name) if swap_llm_name else A_full | |
| # (5) Score via external API (one call per query) | |
| variants = [ | |
| ("full", A_full), | |
| ("remove_key", cf_remove_key), | |
| ("remove_secondary", cf_remove_secondary), | |
| ("add_irrelevant", cf_add_irrelevant), | |
| ("add_redundant", cf_add_redundant), | |
| ("swap_backbone", cf_swap), | |
| ] | |
| docs = [] | |
| for _, a in variants: | |
| docs.append(agent_to_document(a, retriever.llm_desc(a.llm_name), retriever.tool_desc_map)) | |
| api_resp = _http_post_json(args.scoring_url, {"query": q, "documents": docs}, timeout_s=35.0) | |
| scores = _extract_scores(api_resp, n_docs=len(docs)) | |
| scores_np = np.array(scores, dtype=np.float32) | |
| # ranks within the 6-item set (higher score = better rank) | |
| order = np.argsort(-scores_np) | |
| ranks = np.empty_like(order) | |
| ranks[order] = np.arange(1, len(scores_np) + 1) # rank starts at 1 | |
| s_full = float(scores_np[0]) | |
| r_full = int(ranks[0]) | |
| for i, (name, _) in enumerate(variants[1:], start=1): | |
| s_cf = float(scores_np[i]) | |
| r_cf = int(ranks[i]) | |
| ds = s_full - s_cf | |
| dr = float(r_cf - r_full) | |
| ok = 1 if (s_full > s_cf) else 0 | |
| aggs[name].add(ds, dr, ok) | |
| # ---------------------------- | |
| # Print Table 4 (LaTeX rows) | |
| # ---------------------------- | |
| def fmt_row(label: str, key: str) -> str: | |
| m_ds, m_dr, m_ok = aggs[key].summary() | |
| return f"{label} & {m_ds:.4f} & {m_dr:.2f} & {m_ok:.1f}\\% \\\\" | |
| print("\n================ Table 4 rows (LaTeX) ================\n") | |
| print(fmt_row("Remove key tool", "remove_key")) | |
| print(fmt_row("Remove secondary tool", "remove_secondary")) | |
| print(fmt_row("Add irrelevant tool", "add_irrelevant")) | |
| print(fmt_row("Add redundant tool", "add_redundant")) | |
| print(fmt_row("Swap backbone (rank 2--5)", "swap_backbone")) | |
| print("\n------------------------------------------------------") | |
| print(f"N_queries={len(sampled)} | use_gpt={args.use_gpt} | gpt_fail_fallback={gpt_fail_cnt}") | |
| print("======================================================\n") | |
| if __name__ == "__main__": | |
| main() | |
Xet Storage Details
- Size:
- 28.8 kB
- Xet hash:
- 30ce2a9bcb537e9b31ca3357c51697e5270cf7f0b28a305b5b33ef091159db52
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.