text_difficulty_proxy.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614
  1. #!/usr/bin/env python
  2. """
  3. E5 实验:RS_cross 作为文本难度代理(修正版)
  4. 方法说明:
  5. 由于单条文本无法计算有意义的 RS_cross(N=1 时协方差矩阵退化),
  6. 本实验采用"分组难度分析"方法:
  7. 1. 将数据集按文本长度/复杂度分组
  8. 2. 在每个组内计算 RS_cross
  9. 3. 分析 RS_cross 与组平均难度的相关性
  10. 或者采用"模型错误率分组"方法:
  11. 1. 先用模型做题,统计每题的准确率
  12. 2. 按准确率分组(简单题/中等题/困难题)
  13. 3. 计算每组的 RS_cross(用该组所有文本一起计算)
  14. 用法:
  15. python experiments/text_difficulty_proxy.py --dataset bigbench --sample 200
  16. """
  17. import os
  18. import json
  19. import torch
  20. import numpy as np
  21. from pathlib import Path
  22. from typing import Dict, List, Tuple, Optional, Any
  23. from dataclasses import dataclass, asdict
  24. from datetime import datetime
  25. os.environ["PYTORCH_ALLOC_CONF"] = "expandable_segments:True"
  26. os.environ["HF_ENDPOINT"] = "https://hf-mirror.com"
  27. from transformers import AutoTokenizer, AutoModelForCausalLM
  28. from tqdm import tqdm
  29. from scipy.stats import spearmanr, pearsonr
  30. import sys
  31. sys.path.insert(0, str(Path(__file__).parent.parent))
  32. from model.spectrum import compute_gram_spectrum
  33. @dataclass
  34. class E5Config:
  35. """E5 实验配置"""
  36. models: List[str]
  37. model_paths: Dict[str, str]
  38. model_mmlu: Dict[str, float]
  39. dataset: str
  40. output_dir: str
  41. sample_size: int = 200
  42. batch_size: int = 4
  43. max_length: int = 512
  44. @dataclass
  45. class GroupDifficultyResult:
  46. """分组难度分析结果"""
  47. group_name: str # 如"简单"、"中等"、"困难"
  48. text_ids: List[int]
  49. num_texts: int
  50. r_effs: Dict[str, float] # {model_name: r_eff}
  51. rs_cross: float
  52. avg_text_length: float
  53. estimated_difficulty: float # 估计的难度(基于 r_eff)
  54. class TextDifficultyProxy:
  55. """文本难度代理分析器(分组方法)"""
  56. def __init__(self, config: E5Config):
  57. self.config = config
  58. self.output_dir = Path(config.output_dir)
  59. self.output_dir.mkdir(parents=True, exist_ok=True)
  60. self.loaded_models: Dict[str, Tuple[AutoTokenizer, AutoModelForCausalLM]] = {}
  61. self.group_results: List[GroupDifficultyResult] = []
  62. def unload_all_models(self):
  63. """卸载所有模型并清理 GPU 缓存"""
  64. import gc
  65. self.loaded_models.clear()
  66. gc.collect()
  67. if torch.cuda.is_available():
  68. torch.cuda.empty_cache()
  69. def load_model(self, model_name: str):
  70. """加载模型(带缓存)"""
  71. if model_name in self.loaded_models:
  72. return self.loaded_models[model_name]
  73. model_path = self.config.model_paths.get(
  74. model_name,
  75. f"model/weights/{model_name}"
  76. )
  77. print(f"加载模型:{model_name}")
  78. tokenizer = AutoTokenizer.from_pretrained(
  79. model_path,
  80. trust_remote_code=True
  81. )
  82. if tokenizer.pad_token is None:
  83. tokenizer.pad_token = tokenizer.eos_token
  84. if tokenizer.eos_token_id is None:
  85. tokenizer.eos_token_id = 50256
  86. model = AutoModelForCausalLM.from_pretrained(
  87. model_path,
  88. torch_dtype=torch.float16,
  89. device_map="auto",
  90. output_hidden_states=True,
  91. trust_remote_code=True
  92. )
  93. model.eval()
  94. self.loaded_models[model_name] = (tokenizer, model)
  95. return tokenizer, model
  96. def extract_hidden_states_batch(
  97. self,
  98. model_name: str,
  99. texts: List[str]
  100. ) -> torch.Tensor:
  101. """
  102. 批量提取文本的隐层表示
  103. Returns:
  104. H: 表示矩阵 (d, N)
  105. """
  106. tokenizer, model = self.load_model(model_name)
  107. all_features = []
  108. for i in range(0, len(texts), self.config.batch_size):
  109. batch = texts[i:i + self.config.batch_size]
  110. inputs = tokenizer(
  111. batch,
  112. return_tensors="pt",
  113. padding=True,
  114. truncation=True,
  115. max_length=self.config.max_length
  116. ).to(model.device)
  117. with torch.no_grad():
  118. outputs = model(**inputs)
  119. hidden = outputs.hidden_states[-1] # 最后一层
  120. attention_mask = inputs["attention_mask"]
  121. # 对有效 token 取均值
  122. mask_expanded = attention_mask.unsqueeze(-1).float()
  123. feat = (hidden * mask_expanded).sum(1) / (mask_expanded.sum(1) + 1e-10)
  124. all_features.append(feat.cpu().float())
  125. # 转置为 (d, N)
  126. H = torch.cat(all_features, dim=0).T
  127. return H
  128. def compute_group_rs_cross(
  129. self,
  130. texts: List[str],
  131. model_names: List[str]
  132. ) -> Tuple[Dict[str, float], float]:
  133. """
  134. 计算一组文本的 RS_cross
  135. 方法:
  136. 1. 每个模型提取这组文本的表示
  137. 2. 计算每个模型的 r_eff
  138. 3. RS_cross = 跨模型 r_eff 方差
  139. Returns:
  140. r_effs: {model_name: r_eff}
  141. rs_cross: 跨模型 r_eff 方差
  142. """
  143. r_effs = {}
  144. for model_name in tqdm(model_names, desc=f"计算组 r_eff"):
  145. # 提取表示
  146. H = self.extract_hidden_states_batch(model_name, texts)
  147. # 计算有效秩
  148. r_eff = compute_gram_spectrum(H)
  149. r_effs[model_name] = float(r_eff)
  150. # 立即卸载模型释放显存
  151. self.unload_all_models()
  152. # RS_cross = 跨模型 r_eff 方差
  153. values = list(r_effs.values())
  154. rs_cross = float(np.var(values))
  155. return r_effs, rs_cross
  156. def load_texts(self, dataset_name: str, limit: int = 200) -> List[str]:
  157. """加载数据集文本"""
  158. from database.corpus import CorpusLoader
  159. loader = CorpusLoader("database/corpus")
  160. try:
  161. texts = loader.load_texts(dataset_name, limit=limit)
  162. print(f"加载 {dataset_name}: {len(texts)} 条文本")
  163. return texts
  164. except FileNotFoundError:
  165. print(f"警告:数据集 {dataset_name} 不存在")
  166. return []
  167. def group_by_text_length(
  168. self,
  169. texts: List[str],
  170. num_groups: int = 5
  171. ) -> Dict[str, List[int]]:
  172. """
  173. 按文本长度分组(作为难度的代理)
  174. 假设:长文本 = 更复杂 = 更难
  175. """
  176. lengths = [len(t) for t in texts]
  177. indices = np.argsort(lengths)
  178. # 等分为 num_groups 组
  179. group_size = len(indices) // num_groups
  180. groups = {}
  181. for i in range(num_groups):
  182. start = i * group_size
  183. if i == num_groups - 1:
  184. end = len(indices)
  185. else:
  186. end = (i + 1) * group_size
  187. group_indices = indices[start:end].tolist()
  188. group_name = f"group_{i}_({['最短', '较短', '中等', '较长', '最长'][i] if num_groups==5 else i})"
  189. groups[group_name] = group_indices
  190. return groups
  191. def run_analysis(self, texts: List[str]) -> List[GroupDifficultyResult]:
  192. """
  193. 运行分组难度分析
  194. 方法:按文本长度分组,计算每组的 RS_cross
  195. """
  196. results = []
  197. model_names = self.config.models
  198. # 按长度分组
  199. groups = self.group_by_text_length(texts, num_groups=5)
  200. print(f"\n开始分析 {len(texts)} 条文本...")
  201. print(f"分组数:{len(groups)}")
  202. print(f"使用模型:{len(model_names)} 个")
  203. for group_name, group_indices in groups.items():
  204. group_texts = [texts[i] for i in group_indices]
  205. try:
  206. # 计算该组的 RS_cross
  207. r_effs, rs_cross = self.compute_group_rs_cross(group_texts, model_names)
  208. # 计算平均长度
  209. avg_length = np.mean([len(texts[i]) for i in group_indices])
  210. # 估计难度(用平均 r_eff 作为代理)
  211. avg_r_eff = np.mean(list(r_effs.values()))
  212. result = GroupDifficultyResult(
  213. group_name=group_name,
  214. text_ids=group_indices,
  215. num_texts=len(group_indices),
  216. r_effs=r_effs,
  217. rs_cross=rs_cross,
  218. avg_text_length=avg_length,
  219. estimated_difficulty=avg_r_eff
  220. )
  221. results.append(result)
  222. print(f"\n{group_name}:")
  223. print(f" 文本数:{len(group_indices)}")
  224. print(f" 平均长度:{avg_length:.1f}")
  225. print(f" RS_cross: {rs_cross:.4f}")
  226. except Exception as e:
  227. print(f"组 {group_name} 处理失败:{e}")
  228. continue
  229. self.group_results = results
  230. return results
  231. def compute_correlations(self) -> Dict[str, Any]:
  232. """
  233. 计算 RS_cross 与文本长度(难度代理)的相关性
  234. 假设:文本越长 = 越复杂 = RS_cross 越高
  235. """
  236. if not self.group_results:
  237. return {"error": "没有结果数据"}
  238. rs_cross_values = [r.rs_cross for r in self.group_results]
  239. text_lengths = [r.avg_text_length for r in self.group_results]
  240. difficulties = [r.estimated_difficulty for r in self.group_results]
  241. correlations = {}
  242. # RS_cross vs 文本长度
  243. if len(rs_cross_values) >= 3:
  244. spearman_length = spearmanr(rs_cross_values, text_lengths)
  245. correlations["rs_cross_vs_text_length"] = {
  246. "spearman_rho": spearman_length.correlation,
  247. "spearman_pvalue": spearman_length.pvalue,
  248. "n_samples": len(rs_cross_values),
  249. "interpretation": "正相关表示长文本(复杂)导致更高 RS_cross"
  250. }
  251. # RS_cross vs 估计难度
  252. if len(rs_cross_values) >= 3:
  253. spearman_diff = spearmanr(rs_cross_values, difficulties)
  254. correlations["rs_cross_vs_estimated_difficulty"] = {
  255. "spearman_rho": spearman_diff.correlation,
  256. "spearman_pvalue": spearman_diff.pvalue,
  257. "n_samples": len(rs_cross_values),
  258. "interpretation": "正相关表示高难度导致更高 RS_cross"
  259. }
  260. # RS_cross 随组别的变化趋势
  261. if len(self.group_results) >= 3:
  262. group_order = list(range(len(self.group_results)))
  263. spearman_trend = spearmanr(group_order, rs_cross_values)
  264. correlations["rs_cross_trend"] = {
  265. "spearman_rho": spearman_trend.correlation,
  266. "spearman_pvalue": spearman_trend.pvalue,
  267. "interpretation": "正相关表示 RS_cross 随文本长度增加而上升"
  268. }
  269. return correlations
  270. def generate_summary(self) -> Dict[str, Any]:
  271. """生成摘要统计"""
  272. if not self.group_results:
  273. return {}
  274. rs_cross_values = [r.rs_cross for r in self.group_results]
  275. # 按 RS_cross 排序
  276. sorted_groups = sorted(
  277. self.group_results,
  278. key=lambda x: x.rs_cross,
  279. reverse=True # 从高到低
  280. )
  281. return {
  282. "total_groups": len(self.group_results),
  283. "rs_cross_stats": {
  284. "mean": float(np.mean(rs_cross_values)),
  285. "std": float(np.std(rs_cross_values)),
  286. "min": float(np.min(rs_cross_values)),
  287. "max": float(np.max(rs_cross_values)),
  288. },
  289. "groups_sorted_by_rs_cross": [
  290. {
  291. "name": g.group_name,
  292. "rs_cross": g.rs_cross,
  293. "num_texts": g.num_texts,
  294. "avg_length": g.avg_text_length,
  295. "r_effs": g.r_effs
  296. }
  297. for g in sorted_groups
  298. ],
  299. }
  300. def save_results(self):
  301. """保存结果"""
  302. # 保存详细结果
  303. results_dict = []
  304. for r in self.group_results:
  305. results_dict.append({
  306. "group_name": r.group_name,
  307. "text_ids": r.text_ids,
  308. "num_texts": r.num_texts,
  309. "r_effs": r.r_effs,
  310. "rs_cross": r.rs_cross,
  311. "avg_text_length": r.avg_text_length,
  312. "estimated_difficulty": r.estimated_difficulty
  313. })
  314. results_file = self.output_dir / "e5_group_difficulties.json"
  315. with open(results_file, "w") as f:
  316. json.dump(results_dict, f, indent=2, ensure_ascii=False)
  317. # 保存相关性分析
  318. correlations = self.compute_correlations()
  319. corr_file = self.output_dir / "e5_correlations.json"
  320. with open(corr_file, "w") as f:
  321. json.dump(correlations, f, indent=2)
  322. # 保存摘要
  323. summary = self.generate_summary()
  324. summary["correlations"] = correlations
  325. summary_file = self.output_dir / "e5_summary.json"
  326. with open(summary_file, "w") as f:
  327. json.dump(summary, f, indent=2, ensure_ascii=False)
  328. print(f"\n结果已保存到:{self.output_dir}")
  329. print(f" - e5_group_difficulties.json (分组结果)")
  330. print(f" - e5_correlations.json (相关性分析)")
  331. print(f" - e5_summary.json (摘要)")
  332. def generate_report(self):
  333. """生成 Markdown 报告"""
  334. summary = self.generate_summary()
  335. correlations = self.compute_correlations()
  336. report = [
  337. "# E5 实验报告:RS_cross 作为文本难度代理(分组方法)",
  338. "",
  339. f"实验时间:{datetime.now().isoformat()}",
  340. "",
  341. "## 实验配置",
  342. f"- 数据集:{self.config.dataset}",
  343. f"- 文本数量:{sum(g.num_texts for g in self.group_results)}",
  344. f"- 分组数:{len(self.group_results)}",
  345. f"- 模型数量:{len(self.config.models)}",
  346. f"- 模型列表:{', '.join(self.config.models)}",
  347. "",
  348. "## 方法说明",
  349. "",
  350. "由于单条文本无法计算有意义的 RS_cross(N=1 时协方差矩阵退化),",
  351. "本实验采用**分组难度分析**方法:",
  352. "",
  353. "1. 将数据集按文本长度分组(假设:长文本 = 更复杂 = 更难)",
  354. "2. 在每个组内计算跨模型 RS_cross",
  355. "3. 分析 RS_cross 随文本长度的变化趋势",
  356. "",
  357. "## RS_cross 统计",
  358. "",
  359. "| 统计量 | 值 |",
  360. "|--------|-----|",
  361. f"| 均值 | {summary['rs_cross_stats']['mean']:.4f} |",
  362. f"| 标准差 | {summary['rs_cross_stats']['std']:.4f} |",
  363. f"| 最小值 | {summary['rs_cross_stats']['min']:.4f} |",
  364. f"| 最大值 | {summary['rs_cross_stats']['max']:.4f} |",
  365. "",
  366. "## 分组结果(按 RS_cross 排序)",
  367. "",
  368. "| 组名 | 文本数 | 平均长度 | RS_cross | 平均 r_eff |",
  369. "|------|--------|----------|----------|------------|",
  370. ]
  371. for g in summary.get('groups_sorted_by_rs_cross', []):
  372. avg_r_eff = np.mean(list(g['r_effs'].values()))
  373. report.append(
  374. f"| {g['name']} | {g['num_texts']} | {g['avg_length']:.1f} | "
  375. f"{g['rs_cross']:.4f} | {avg_r_eff:.2f} |"
  376. )
  377. report.extend([
  378. "",
  379. "## 相关性分析",
  380. "",
  381. ])
  382. if "rs_cross_vs_text_length" in correlations:
  383. corr = correlations["rs_cross_vs_text_length"]
  384. report.extend([
  385. "### RS_cross vs 文本长度",
  386. "",
  387. f"- Spearman ρ: **{corr['spearman_rho']:.4f}** (p={corr['spearman_pvalue']:.4f})",
  388. f"- 样本数:{corr['n_samples']}",
  389. f"- 解释:{corr['interpretation']}",
  390. "",
  391. ])
  392. # 评估相关性强度
  393. rho = abs(corr['spearman_rho'])
  394. if rho > 0.7:
  395. strength = "强相关 ✓"
  396. elif rho > 0.5:
  397. strength = "中等相关 ✓"
  398. elif rho > 0.3:
  399. strength = "弱相关 △"
  400. else:
  401. strength = "几乎不相关 ✗"
  402. report.extend([
  403. f"**相关性强度**: {strength}",
  404. "",
  405. ])
  406. if "rs_cross_trend" in correlations:
  407. corr = correlations["rs_cross_trend"]
  408. report.extend([
  409. "### RS_cross 随文本长度组的变化趋势",
  410. "",
  411. f"- Spearman ρ: **{corr['spearman_rho']:.4f}** (p={corr['spearman_pvalue']:.4f})",
  412. f"- 解释:{corr['interpretation']}",
  413. "",
  414. ])
  415. report.extend([
  416. "## 结论",
  417. "",
  418. ])
  419. if "rs_cross_vs_text_length" in correlations:
  420. rho = correlations["rs_cross_vs_text_length"]["spearman_rho"]
  421. pval = correlations["rs_cross_vs_text_length"]["spearman_pvalue"]
  422. if rho > 0.5 and pval < 0.1:
  423. conclusion = (
  424. f"✅ **假设获支持**:RS_cross 与文本长度呈显著正相关 (ρ={rho:.4f}, p={pval:.4f})。\n\n"
  425. "这表明**文本复杂度越高,跨模型表示分歧越大**,"
  426. "RS_cross 可以作为文本难度的无监督代理指标。"
  427. )
  428. elif rho > 0.3:
  429. conclusion = (
  430. f"△ **假设部分支持**:RS_cross 与文本长度呈弱到中等相关 (ρ={rho:.4f}, p={pval:.4f})。\n\n"
  431. "趋势存在但不够强,可能需要更多数据点或更细粒度的难度划分。"
  432. )
  433. else:
  434. conclusion = (
  435. f"✗ **假设未获支持**:RS_cross 与文本长度几乎不相关 (ρ={rho:.4f}, p={pval:.4f})。\n\n"
  436. "可能需要重新审视'文本长度=难度'的假设,或使用其他难度指标(如模型准确率)。"
  437. )
  438. report.append(conclusion)
  439. report.extend([
  440. "",
  441. "## 局限性",
  442. "",
  443. "1. **文本长度≠难度**: 文本长度只是难度的粗糙代理,有些短文本可能很难(如数学证明),有些长文本可能很简单(如重复叙述)",
  444. "2. **分组数量少**: 仅 5 组,相关性统计检验力有限",
  445. "3. **单数据集**: 仅在 bigbench 上验证,需要更多数据集验证普适性",
  446. "",
  447. "## 未来改进方向",
  448. "",
  449. "1. **使用真实难度标注**: 用模型在题目上的实际准确率作为难度标签",
  450. "2. **滑动窗口**: 使用滑动窗口代替硬分组,获得更平滑的难度曲线",
  451. "3. **多维度难度**: 考虑文本的多维度难度(推理、知识、语言复杂度等)",
  452. ])
  453. report_file = self.output_dir / "e5_report.md"
  454. with open(report_file, "w") as f:
  455. f.write("\n".join(report))
  456. print(f"报告已保存到:{report_file}")
  457. def main():
  458. import argparse
  459. parser = argparse.ArgumentParser(description="E5 实验:RS_cross 作为文本难度代理")
  460. parser.add_argument("--dataset", type=str, default="bigbench",
  461. help="数据集名称")
  462. parser.add_argument("--sample", type=int, default=200,
  463. help="采样文本数量")
  464. parser.add_argument("--output-dir", type=str,
  465. default="experiments/output/e5",
  466. help="输出目录")
  467. args = parser.parse_args()
  468. # 实验配置
  469. config = E5Config(
  470. models=[
  471. "llama3.2-3b-instruct",
  472. "Mistral-7B-v0.3",
  473. "llama3-8b",
  474. "Qwen2.5-7B-Instruct",
  475. "gemma-2-9b-it"
  476. ],
  477. model_paths={
  478. "llama3.2-3b-instruct": "model/weights/llama3.2-3b-instruct",
  479. "Mistral-7B-v0.3": "model/weights/Mistral-7B-v0.3",
  480. "llama3-8b": "model/weights/llama3-8b",
  481. "Qwen2.5-7B-Instruct": "model/weights/Qwen2.5-7B-Instruct",
  482. "gemma-2-9b-it": "model/weights/gemma-2-9b-it"
  483. },
  484. model_mmlu={
  485. "llama3.2-3b-instruct": 58.0,
  486. "Mistral-7B-v0.3": 62.5,
  487. "llama3-8b": 68.4,
  488. "Qwen2.5-7B-Instruct": 86.3,
  489. "gemma-2-9b-it": 82.0
  490. },
  491. dataset=args.dataset,
  492. output_dir=args.output_dir,
  493. sample_size=args.sample,
  494. batch_size=4,
  495. max_length=512
  496. )
  497. analyzer = TextDifficultyProxy(config)
  498. # 加载文本
  499. texts = analyzer.load_texts(args.dataset, limit=args.sample)
  500. if not texts:
  501. print("未找到文本,退出")
  502. return
  503. # 运行分析
  504. analyzer.run_analysis(texts)
  505. # 保存结果
  506. analyzer.save_results()
  507. # 生成报告
  508. analyzer.generate_report()
  509. if __name__ == "__main__":
  510. main()