From 396ee95aabe9028a7d246a6137fb6b8cc724cdc4 Mon Sep 17 00:00:00 2001 From: tassel51 <2775297866@qq.com> Date: Tue, 1 Sep 2026 17:10:23 +0800 Subject: [PATCH] feat: add format reward comparison experiment and utilities - Add train_compare.py: Format reward vs baseline comparison experiment - Pure sampling-based comparison (no SFT training needed) - Works on RTX 4060 with 8GB VRAM - Tests EM accuracy and format compliance - Add reward_format.py: Format reward functions - think/search/answer tag detection - Combined reward scoring - Add infer_4060.py: Simplified inference for RTX 4060 - Inline search (no threading issues) - Chat template support - Add plot_training.py: Training visualization - Add download_with_retry.py: Download utility with retry logic Tested on Qwen2.5-3B with 40 NQ questions. --- download_with_retry.py | 145 +++++++++++++++++ infer_4060.py | 216 ++++++++++++++++++++++++ plot_training.py | 206 +++++++++++++++++++++++ reward_format.py | 225 +++++++++++++++++++++++++ train_compare.py | 361 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1153 insertions(+) create mode 100644 download_with_retry.py create mode 100644 infer_4060.py create mode 100644 plot_training.py create mode 100644 reward_format.py create mode 100644 train_compare.py diff --git a/download_with_retry.py b/download_with_retry.py new file mode 100644 index 000000000..f530233eb --- /dev/null +++ b/download_with_retry.py @@ -0,0 +1,145 @@ +""" +带重试的下载脚本 - 下载NQ数据和wiki-18语料 +支持断点续传和自动重试 +""" +import os +import sys +import time +import json +import subprocess + +# 设置镜像 +os.environ['HF_ENDPOINT'] = 'https://hf-mirror.com' +os.environ['HF_HUB_DOWNLOAD_TIMEOUT'] = '600' + +print('='*60) +print('Search-R1 数据下载(带重试)') +print('='*60) + +# Step 1: 下载NQ数据集 +print('\n[Step 1] 下载NQ数据集 (RUC-NLPIR/FlashRAG_datasets)...') + +max_retries = 5 +for attempt in range(max_retries): + try: + result = subprocess.run( + [sys.executable, '-c', """ +import os +os.environ['HF_ENDPOINT'] = 'https://hf-mirror.com' +os.environ['HF_HUB_DOWNLOAD_TIMEOUT'] = '600' +from datasets import load_dataset +print('Loading NQ dataset...') +ds = load_dataset('RUC-NLPIR/FlashRAG_datasets', 'nq', cache_dir='D:/Search-R1/data_cache') +print(f'Train: {len(ds["train"])} examples') +print(f'Test: {len(ds["test"])} examples') +print(f'Features: {list(ds["train"].features.keys())}') +# 保存前100条训练数据 +import json +train_data = [] +for i, item in enumerate(ds['train']): + if i >= 100: + break + train_data.append(item) +with open('D:/Search-R1/data/nq_train_sample.json', 'w') as f: + json.dump(train_data, f, indent=2, ensure_ascii=False) +print(f'Saved {len(train_data)} train examples to nq_train_sample.json') +# 保存测试数据 +test_data = [] +for i, item in enumerate(ds['test']): + if i >= 50: + break + test_data.append(item) +with open('D:/Search-R1/data/nq_test_sample.json', 'w') as f: + json.dump(test_data, f, indent=2, ensure_ascii=False) +print(f'Saved {len(test_data)} test examples to nq_test_sample.json') +"""], + capture_output=True, text=True, timeout=600 + ) + if result.returncode == 0: + print(result.stdout) + print('NQ数据集下载成功!') + break + else: + print(f' 尝试 {attempt+1}/{max_retries} 失败') + print(f' 错误: {result.stderr[-200:]}') + time.sleep(5) + except Exception as e: + print(f' 尝试 {attempt+1}/{max_retries} 异常: {e}') + time.sleep(5) + +# Step 2: 下载BM25索引(较小的文件) +print('\n[Step 2] 尝试下载wiki-18 BM25索引...') +for attempt in range(3): + try: + result = subprocess.run( + [sys.executable, '-c', """ +import os +os.environ['HF_ENDPOINT'] = 'https://hf-mirror.com' +from huggingface_hub import hf_hub_download +print('Downloading BM25 index...') +hf_hub_download( + repo_id='PeterJinGo/wiki-18-bm25-index', + filename='bm25', + repo_type='dataset', + local_dir='D:/Search-R1/data', +) +print('BM25 index downloaded!') +"""], + capture_output=True, text=True, timeout=600 + ) + if result.returncode == 0: + print(result.stdout) + print('BM25索引下载成功!') + break + else: + print(f' 尝试 {attempt+1}/3 失败: {result.stderr[-200:]}') + time.sleep(10) + except Exception as e: + print(f' 尝试 {attempt+1}/3 异常: {e}') + time.sleep(10) + +# Step 3: 下载wiki-18语料(只下载一部分) +print('\n[Step 3] 尝试下载wiki-18语料库...') +# wiki-18.jsonl.gz 太大(27GB),尝试下载一个较小的版本 +for attempt in range(3): + try: + result = subprocess.run( + [sys.executable, '-c', """ +import os +os.environ['HF_ENDPOINT'] = 'https://hf-mirror.com' +from huggingface_hub import hf_hub_download +print('Downloading wiki-18 corpus (this is ~27GB, may take a while)...') +try: + hf_hub_download( + repo_id='PeterJinGo/wiki-18-corpus', + filename='wiki-18.jsonl.gz', + repo_type='dataset', + local_dir='D:/Search-R1/data', + ) + print('wiki-18 corpus downloaded!') +except Exception as e: + print(f'Download failed: {e}') + print('Will use a smaller corpus instead.') +"""], + capture_output=True, text=True, timeout=1200 + ) + print(result.stdout[-500:]) + if result.returncode == 0 and 'downloaded!' in result.stdout: + print('wiki-18语料下载成功!') + break + else: + print(f' 尝试 {attempt+1}/3: 语料太大或网络不稳') + except Exception as e: + print(f' 尝试 {attempt+1}/3 异常: {e}') + +# 检查下载结果 +print('\n' + '='*60) +print('下载结果检查') +print('='*60) +for f in ['nq_train_sample.json', 'nq_test_sample.json', 'bm25', 'wiki-18.jsonl.gz']: + path = f'D:/Search-R1/data/{f}' + if os.path.exists(path): + size = os.path.getsize(path) + print(f'[OK] {f}: {size/1024/1024:.1f}MB') + else: + print(f'[MISSING] {f}') diff --git a/infer_4060.py b/infer_4060.py new file mode 100644 index 000000000..25cad9f43 --- /dev/null +++ b/infer_4060.py @@ -0,0 +1,216 @@ +""" +Search-R1 推理脚本 - 适配RTX 4060 (8GB显存) +使用Qwen2.5-3B模型进行推理测试 +""" + +import transformers +import torch +import re +import requests +import gc + +# ==================== 配置部分 ==================== +# 模型选择(3B模型适合8GB显存) +MODEL_ID = "Qwen/Qwen2.5-3B" # 或使用本地路径 "D:/Search-R1/models/qwen2.5-3b" + +# 测试问题 +QUESTIONS = [ + "Who is the first president of the United States?", + "What is the capital of France?", + "When did World War II end?", + "Who wrote the novel '1984'?", + "What is the largest planet in our solar system?", +] + +# 搜索引擎地址(需要先启动检索服务器) +SEARCH_URL = "http://127.0.0.1:8000/retrieve" + +# ==================== 工具函数 ==================== + +def clear_gpu_memory(): + """清理GPU显存""" + gc.collect() + if torch.cuda.is_available(): + torch.cuda.empty_cache() + print(f"GPU显存: {torch.cuda.memory_allocated()/1024**3:.2f}GB / " + f"{torch.cuda.memory_reserved()/1024**3:.2f}GB") + + +def get_query(text): + """从模型输出中提取搜索查询""" + pattern = re.compile(r"(.*?)", re.DOTALL) + matches = pattern.findall(text) + if matches: + return matches[-1] + return None + + +def search(query): + """调用搜索引擎""" + try: + payload = { + "queries": [query], + "topk": 3, + "return_scores": True + } + results = requests.post(SEARCH_URL, json=payload, timeout=10).json()['result'] + + def _passages2string(retrieval_result): + format_reference = '' + for idx, doc_item in enumerate(retrieval_result): + content = doc_item['document']['contents'] + title = content.split("\n")[0] + text = "\n".join(content.split("\n")[1:]) + format_reference += f"Doc {idx+1}(Title: {title}) {text}\n" + return format_reference + + return _passages2string(results[0]) + except Exception as e: + print(f"搜索失败: {e}") + return "搜索服务不可用,请检查检索服务器是否启动。" + + +# ==================== 主程序 ==================== + +def main(): + print("=" * 60) + print("Search-R1 推理测试 (RTX 4060 适配版)") + print("=" * 60) + + # 检查GPU + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + print(f"使用设备: {device}") + if torch.cuda.is_available(): + print(f"GPU型号: {torch.cuda.get_device_name(0)}") + print(f"显存总量: {torch.cuda.get_device_properties(0).total_memory/1024**3:.1f}GB") + + # 加载模型 + print(f"\n正在加载模型: {MODEL_ID}") + print("这可能需要几分钟...") + + try: + tokenizer = transformers.AutoTokenizer.from_pretrained(MODEL_ID) + model = transformers.AutoModelForCausalLM.from_pretrained( + MODEL_ID, + torch_dtype=torch.float16, # 使用float16而非bfloat16 + device_map="auto", + low_cpu_mem_usage=True + ) + print("模型加载成功!") + clear_gpu_memory() + except Exception as e: + print(f"模型加载失败: {e}") + print("请检查:") + print("1. 模型是否已下载到本地") + print("2. 显存是否足够") + return + + # Prompt模板 + prompt_template = """Answer the given question. \ +You must conduct reasoning inside and first every time you get new information. \ +After reasoning, if you find you lack some knowledge, you can call a search engine by query and it will return the top searched results between and . \ +You can search as many times as your want. \ +If you find no further external knowledge needed, you can directly provide the answer inside and , without detailed illustrations. For example, Beijing . Question: {question}\n""" + + # 停止条件 + curr_eos = [151645, 151643] # Qwen2.5的EOS token + curr_search_template = '\n\n{output_text}{search_results}\n\n' + + target_sequences = ["", " ", "\n"] + target_ids = [tokenizer.encode(seq, add_special_tokens=False) for seq in target_sequences] + target_lengths = [len(ids) for ids in target_ids] + + class StopOnSequence(transformers.StoppingCriteria): + def __init__(self, target_ids, target_lengths): + self.target_ids = target_ids + self.target_lengths = target_lengths + + def __call__(self, input_ids, scores, **kwargs): + if input_ids.shape[1] < min(self.target_lengths): + return False + for target, length in zip(self.target_ids, self.target_lengths): + if torch.equal(input_ids[0, -length:], torch.as_tensor(target, device=input_ids.device)): + return True + return False + + stopping_criteria = transformers.StoppingCriteriaList([StopOnSequence(target_ids, target_lengths)]) + + # 测试每个问题 + for q_idx, question in enumerate(QUESTIONS): + print(f"\n{'='*60}") + print(f"问题 {q_idx+1}: {question}") + print("=" * 60) + + if question.strip()[-1] != '?': + question = question.strip() + '?' + else: + question = question.strip() + + prompt = prompt_template.format(question=question) + + if tokenizer.chat_template: + prompt = tokenizer.apply_chat_template( + [{"role": "user", "content": prompt}], + add_generation_prompt=True, + tokenize=False + ) + + print(f"\n[Prompt]\n{prompt[:200]}...") + + turn = 0 + max_turns = 3 # 4060上限制轮数 + + while turn < max_turns: + turn += 1 + print(f"\n--- 第{turn}轮生成 ---") + + input_ids = tokenizer.encode(prompt, return_tensors='pt').to(device) + attention_mask = torch.ones_like(input_ids) + + with torch.no_grad(): + outputs = model.generate( + input_ids, + attention_mask=attention_mask, + max_new_tokens=512, # 减小以节省显存 + stopping_criteria=stopping_criteria, + pad_token_id=tokenizer.eos_token_id, + do_sample=True, + temperature=0.7 + ) + + if outputs[0][-1].item() in curr_eos: + generated_tokens = outputs[0][input_ids.shape[1]:] + output_text = tokenizer.decode(generated_tokens, skip_special_tokens=True) + print(f"[最终回答] {output_text}") + break + + generated_tokens = outputs[0][input_ids.shape[1]:] + output_text = tokenizer.decode(generated_tokens, skip_special_tokens=True) + + # 尝试搜索 + full_text = tokenizer.decode(outputs[0], skip_special_tokens=True) + query = get_query(full_text) + + if query: + print(f"[搜索查询] {query}") + search_results = search(query) + search_text = curr_search_template.format( + output_text=output_text, + search_results=search_results + ) + prompt += search_text + print(f"[搜索结果] {search_results[:200]}...") + else: + print(f"[模型输出] {output_text}") + prompt += output_text + + clear_gpu_memory() + print() + + print("\n" + "=" * 60) + print("推理测试完成!") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/plot_training.py b/plot_training.py new file mode 100644 index 000000000..ab8197d21 --- /dev/null +++ b/plot_training.py @@ -0,0 +1,206 @@ +""" +Search-R1 训练结果可视化 +读取training_log.json,生成一系列图表 +""" +import json +import os + +# 检查日志文件 +log_path = 'D:/Search-R1/training_log.json' +if not os.path.exists(log_path): + print('training_log.json not found. Please run train_full_fixed.py first.') + exit(1) + +with open(log_path, 'r') as f: + data = json.load(f) + +steps = data['steps'] +config = data['config'] +summary = data.get('summary', {}) +test_results = data.get('test_results', []) + +# 提取数据 +step_nums = [s['step'] for s in steps] +rewards = [s['reward'] for s in steps] +step_accs = [s['step_acc'] for s in steps] +overall_accs = [s['overall_acc'] for s in steps] +times = [s['time'] for s in steps] + +import matplotlib +matplotlib.use('Agg') # 不需要GUI +import matplotlib.pyplot as plt +import numpy as np + +# 设置中文字体 +plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei', 'DejaVu Sans'] +plt.rcParams['axes.unicode_minus'] = False + +output_dir = 'D:/Search-R1/plots' +os.makedirs(output_dir, exist_ok=True) + +# ==================== 图1: 奖励曲线 ==================== +fig, ax = plt.subplots(figsize=(12, 6)) +ax.plot(step_nums, rewards, 'b-', alpha=0.3, linewidth=0.8, label='Step Reward') +# 滑动平均 +window = min(10, len(rewards)) +if len(rewards) >= window: + moving_avg = np.convolve(rewards, np.ones(window)/window, mode='valid') + ax.plot(step_nums[window-1:], moving_avg, 'r-', linewidth=2, label=f'Moving Average ({window})') +ax.set_xlabel('Training Step', fontsize=12) +ax.set_ylabel('Average Reward', fontsize=12) +ax.set_title(f'Search-R1 GRPO Training - Reward Curve\n' + f'Model: {config["model"]} | Data: {config["data"]} NQ questions | Steps: {config["steps"]}', + fontsize=14) +ax.legend(fontsize=11) +ax.grid(True, alpha=0.3) +ax.set_xlim(0, max(step_nums) + 1) +ax.set_ylim(-0.05, 1.05) +plt.tight_layout() +plt.savefig(f'{output_dir}/1_reward_curve.png', dpi=150) +plt.close() +print(f'[OK] 1_reward_curve.png') + +# ==================== 图2: 准确率曲线 ==================== +fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6)) + +# 左图:每步准确率 +ax1.plot(step_nums, step_accs, 'g-', alpha=0.3, linewidth=0.8, label='Step Accuracy') +window = min(10, len(step_accs)) +if len(step_accs) >= window: + ma = np.convolve(step_accs, np.ones(window)/window, mode='valid') + ax1.plot(step_nums[window-1:], ma, 'g-', linewidth=2, label=f'Moving Avg ({window})') +ax1.set_xlabel('Training Step', fontsize=12) +ax1.set_ylabel('Accuracy', fontsize=12) +ax1.set_title('Step Accuracy (per batch)', fontsize=14) +ax1.legend(fontsize=11) +ax1.grid(True, alpha=0.3) +ax1.set_ylim(-0.05, 1.05) + +# 右图:累计准确率 +ax2.plot(step_nums, overall_accs, 'm-', linewidth=2, label='Overall Accuracy') +ax2.axhline(y=0.5, color='gray', linestyle='--', alpha=0.5, label='50% baseline') +ax2.set_xlabel('Training Step', fontsize=12) +ax2.set_ylabel('Cumulative Accuracy', fontsize=12) +ax2.set_title('Overall Accuracy (cumulative)', fontsize=14) +ax2.legend(fontsize=11) +ax2.grid(True, alpha=0.3) +ax2.set_ylim(-0.05, 1.05) + +plt.suptitle(f'Search-R1 Training - Accuracy (Final: {overall_accs[-1]:.1%})', fontsize=16, y=1.02) +plt.tight_layout() +plt.savefig(f'{output_dir}/2_accuracy_curve.png', dpi=150, bbox_inches='tight') +plt.close() +print(f'[OK] 2_accuracy_curve.png') + +# ==================== 图3: 训练速度 ==================== +fig, ax = plt.subplots(figsize=(12, 5)) +ax.bar(step_nums, times, color='steelblue', alpha=0.6, width=0.8) +ax.axhline(y=np.mean(times), color='red', linestyle='--', linewidth=2, label=f'Avg: {np.mean(times):.0f}s') +ax.set_xlabel('Training Step', fontsize=12) +ax.set_ylabel('Time per Step (seconds)', fontsize=12) +ax.set_title(f'Training Speed | Total time: {sum(times)/3600:.1f} hours | Avg: {np.mean(times):.0f}s/step', + fontsize=14) +ax.legend(fontsize=11) +ax.grid(True, alpha=0.3, axis='y') +plt.tight_layout() +plt.savefig(f'{output_dir}/3_training_speed.png', dpi=150) +plt.close() +print(f'[OK] 3_training_speed.png') + +# ==================== 图4: 测试结果 ==================== +if test_results: + fig, ax = plt.subplots(figsize=(14, 8)) + questions = [t['question'][:40] for t in test_results] + correct = [1 if t['correct'] else 0 for t in test_results] + answers = [t['answer'] if t['answer'] else 'None' for t in test_results] + goldens = [t['golden'] for t in test_results] + + colors = ['#2ecc71' if c else '#e74c3c' for c in correct] + bars = ax.barh(range(len(questions)), correct, color=colors, height=0.6) + + # 标注答案 + for i, (ans, gold, c) in enumerate(zip(answers, goldens, correct)): + label = f'{ans} (correct: {gold})' if c else f'{ans} (answer: {gold})' + ax.text(0.02, i, label, va='center', fontsize=9, color='white' if c else 'black') + + ax.set_yticks(range(len(questions))) + ax.set_yticklabels(questions, fontsize=10) + ax.set_xlabel('Correct (1) / Wrong (0)', fontsize=12) + acc = sum(correct) / len(correct) + ax.set_title(f'Post-Training Test Results ({sum(correct)}/{len(correct)} = {acc:.0%})', fontsize=14) + ax.set_xlim(-0.1, 1.3) + ax.axvline(x=0.5, color='gray', linestyle='--', alpha=0.5) + plt.tight_layout() + plt.savefig(f'{output_dir}/4_test_results.png', dpi=150) + plt.close() + print(f'[OK] 4_test_results.png') + +# ==================== 图5: 综合仪表盘 ==================== +fig, axes = plt.subplots(2, 2, figsize=(16, 12)) + +# 左上:奖励曲线 +ax = axes[0][0] +ax.plot(step_nums, rewards, 'b-', alpha=0.3, linewidth=0.8) +if len(rewards) >= 10: + ma = np.convolve(rewards, np.ones(10)/10, mode='valid') + ax.plot(step_nums[9:], ma, 'r-', linewidth=2, label='Avg (10)') +ax.set_title('GRPO Reward Curve', fontsize=13) +ax.set_xlabel('Step') +ax.set_ylabel('Reward') +ax.legend() +ax.grid(True, alpha=0.3) + +# 右上:累计准确率 +ax = axes[0][1] +ax.plot(step_nums, overall_accs, 'm-', linewidth=2) +ax.axhline(y=0.5, color='gray', linestyle='--', alpha=0.5) +ax.set_title('Cumulative Accuracy', fontsize=13) +ax.set_xlabel('Step') +ax.set_ylabel('Accuracy') +ax.set_ylim(-0.05, 1.05) +ax.grid(True, alpha=0.3) + +# 左下:训练速度 +ax = axes[1][0] +ax.bar(step_nums, times, color='steelblue', alpha=0.5, width=0.8) +ax.axhline(y=np.mean(times), color='red', linestyle='--', linewidth=2) +ax.set_title(f'Training Speed (avg {np.mean(times):.0f}s/step)', fontsize=13) +ax.set_xlabel('Step') +ax.set_ylabel('Time (s)') +ax.grid(True, alpha=0.3, axis='y') + +# 右下:统计信息 +ax = axes[1][1] +ax.axis('off') +info_text = ( + f"===== Search-R1 Training Summary =====\n\n" + f"Model: {config['model']}\n" + f"GPU: {config['gpu']}\n" + f"Training Steps: {config['steps']}\n" + f"Batch Size: {config['batch']}\n" + f"N-Agent: {config['n_agent']}\n" + f"Learning Rate: {config['lr']}\n\n" + f"Dataset: {config['data']} NQ questions\n\n" + f"--- Results ---\n" + f"Final Reward: {rewards[-1]:.3f}\n" + f"Best Reward: {summary.get('best_reward', max(rewards)):.3f}\n" + f"Training Accuracy: {summary.get('training_acc', overall_accs[-1]):.1%}\n" + f"Test Accuracy: {summary.get('test_acc', 0):.0%}\n" + f"Total Time: {sum(times)/3600:.1f} hours\n" +) +ax.text(0.05, 0.95, info_text, transform=ax.transAxes, fontsize=12, + verticalalignment='top', fontfamily='monospace', + bbox=dict(boxstyle='round', facecolor='lightyellow', alpha=0.8)) + +plt.suptitle('Search-R1 GRPO Training Dashboard', fontsize=18, y=0.98) +plt.tight_layout() +plt.savefig(f'{output_dir}/5_dashboard.png', dpi=150, bbox_inches='tight') +plt.close() +print(f'[OK] 5_dashboard.png') + +print(f'\nAll plots saved to: {output_dir}/') +print('Files:') +for f in sorted(os.listdir(output_dir)): + if f.endswith('.png'): + size = os.path.getsize(f'{output_dir}/{f}') / 1024 + print(f' {f} ({size:.0f}KB)') diff --git a/reward_format.py b/reward_format.py new file mode 100644 index 000000000..2c7a1bee3 --- /dev/null +++ b/reward_format.py @@ -0,0 +1,225 @@ +""" +Search-R1 改进:格式奖励函数 +在原始EM奖励的基础上,增加格式规范性奖励 + +改进思路: +原始Search-R1只使用EM(Exact Match)作为奖励,奖励信号非常稀疏。 +通过增加格式奖励,可以引导模型正确使用标签, +加速训练收敛,减少格式错误。 + +奖励组成: +- 格式奖励 (0.3分):正确使用标签格式 +- 过程奖励 (0.2分):推理和搜索过程的质量 +- 最终奖励 (0.5分):答案的EM匹配 +""" + +import re + + +def extract_solution(text): + """ + 从模型输出中提取答案 + 要求至少有2个标签(确保经过了推理/搜索阶段) + """ + matches = re.findall(r'(.*?)', text, re.DOTALL) + if len(matches) < 2: + return None + return matches[-1].strip() + + +def normalize_answer(answer): + """标准化答案,用于EM匹配""" + # 转小写 + answer = answer.lower() + # 去除标点 + answer = re.sub(r'[^\w\s]', '', answer) + # 去除冠词 + answer = re.sub(r'\b(a|an|the)\b', ' ', answer) + # 去除多余空格 + answer = ' '.join(answer.split()) + return answer + + +def exact_match_score(prediction, ground_truth): + """计算EM分数""" + normalized_pred = normalize_answer(prediction) + normalized_gt = normalize_answer(ground_truth) + return 1.0 if normalized_pred == normalized_gt else 0.0 + + +def compute_format_reward(text): + """ + 计算格式奖励 + + 检查模型是否正确使用了各种标签: + - 标签:必须成对出现 + - 标签:如果使用了搜索 + - 标签:必须有至少2个(确保经过推理阶段) + """ + reward = 0.0 + + # 检查标签 + think_open = text.count('') + think_close = text.count('') + if think_open >= 1 and think_open == think_close: + reward += 0.1 # 正确使用了推理标签 + + # 检查标签(如果使用了搜索) + search_matches = re.findall(r'(.*?)', text, re.DOTALL) + if len(search_matches) > 0: + # 检查搜索查询是否非空 + for query in search_matches: + if query.strip(): + reward += 0.05 # 每个有效搜索查询加分 + reward = min(reward, 0.15) # 搜索部分最多0.15分 + + # 检查标签 + answer_matches = re.findall(r'(.*?)', text, re.DOTALL) + if len(answer_matches) >= 2: + reward += 0.15 # 有至少2个answer标签,说明经过了推理阶段 + elif len(answer_matches) == 1: + reward += 0.05 # 只有1个answer标签,部分奖励 + + return min(reward, 0.3) # 格式奖励最多0.3分 + + +def compute_process_reward(text): + """ + 计算过程奖励 + + 评估推理和搜索过程的质量: + - 推理内容是否合理 + - 搜索查询是否与问题相关 + - 是否避免了重复搜索 + """ + reward = 0.0 + + # 检查推理内容 + think_matches = re.findall(r'(.*?)', text, re.DOTALL) + if think_matches: + # 简单检查推理内容长度(太短可能说明没有认真推理) + avg_think_length = sum(len(t) for t in think_matches) / len(think_matches) + if avg_think_length > 20: + reward += 0.1 # 推理内容有一定长度 + + # 检查搜索查询 + search_matches = re.findall(r'(.*?)', text, re.DOTALL) + if len(search_matches) > 0: + # 检查是否有重复搜索 + unique_queries = set(q.strip().lower() for q in search_matches) + if len(unique_queries) == len(search_matches): + reward += 0.1 # 没有重复搜索 + + # 检查搜索次数是否合理(1-3次比较好) + if 1 <= len(search_matches) <= 3: + reward += 0.05 + + return min(reward, 0.2) # 过程奖励最多0.2分 + + +def compute_reward_with_format(prediction, ground_truth, text): + """ + 计算综合奖励 + + 参数: + prediction: 模型预测的答案 + ground_truth: 标准答案列表 + text: 模型的完整输出(包含推理、搜索、答案) + + 返回: + total_reward: 总奖励 (0-1) + breakdown: 奖励分解详情 + """ + # 1. EM奖励 (0.5分) + em_score = 0.0 + if prediction: + for gt in ground_truth: + if exact_match_score(prediction, gt) > 0: + em_score = 0.5 + break + + # 2. 格式奖励 (0.3分) + format_reward = compute_format_reward(text) + + # 3. 过程奖励 (0.2分) + process_reward = compute_process_reward(text) + + # 总奖励 + total_reward = em_score + format_reward + process_reward + + breakdown = { + 'em_score': em_score, + 'format_reward': format_reward, + 'process_reward': process_reward, + 'total_reward': total_reward + } + + return total_reward, breakdown + + +def compute_reward_original(prediction, ground_truth): + """ + 原始Search-R1的奖励函数(用于对比) + 只使用EM匹配,二元奖励 + """ + if prediction is None: + return 0.0 + + for gt in ground_truth: + if exact_match_score(prediction, gt) > 0: + return 1.0 + return 0.0 + + +# ==================== 测试代码 ==================== + +if __name__ == "__main__": + # 测试用例 + test_cases = [ + { + "text": """I need to find the first president of the United States. +first president of the United States + +George Washington was the first president of the United States... + +Based on the search results, George Washington was the first president. + + George Washington """, + "ground_truth": ["George Washington", "Washington"], + "prediction": "George Washington" + }, + { + "text": """Let me think about this. + Paris """, + "ground_truth": ["Paris"], + "prediction": "Paris" + }, + { + "text": """The capital of France is Paris.""", + "ground_truth": ["Paris"], + "prediction": "Paris" + } + ] + + print("=" * 60) + print("格式奖励函数测试") + print("=" * 60) + + for i, case in enumerate(test_cases): + print(f"\n测试用例 {i+1}:") + print(f"文本: {case['text'][:100]}...") + + # 新方法奖励 + new_reward, breakdown = compute_reward_with_format( + case['prediction'], case['ground_truth'], case['text'] + ) + print(f"\n改进后奖励: {new_reward:.3f}") + print(f" - EM奖励: {breakdown['em_score']:.3f}") + print(f" - 格式奖励: {breakdown['format_reward']:.3f}") + print(f" - 过程奖励: {breakdown['process_reward']:.3f}") + + # 原始方法奖励 + old_reward = compute_reward_original(case['prediction'], case['ground_truth']) + print(f"原始奖励: {old_reward:.3f}") + + print("-" * 40) diff --git a/train_compare.py b/train_compare.py new file mode 100644 index 000000000..abc673499 --- /dev/null +++ b/train_compare.py @@ -0,0 +1,361 @@ +""" +Search-R1 对比实验:基线(EM) vs 格式奖励(Format Reward) +纯RL选择法:不做SFT训练,只通过采样+选择来对比两种奖励的效果 +使用 Qwen2.5-3B +""" +import os, json, re, time, gc, random +os.environ['CUDA_VISIBLE_DEVICES'] = '0' +os.environ['SAFETENSORS_FAST_MMAP'] = '0' + +import torch +import transformers + +# ==================== 配置 ==================== +MODEL_PATH = 'D:/Search-R1/modelscope/Qwen/Qwen2___5-3B' +N_AGENT = 3 # 每题采样3次 +N_STEPS = 20 # 20步 +MAX_NEW_TOKENS = 150 + +# ==================== 数据 ==================== +nq_data = [ + ("total number of death row inmates in the us", ["2,718"]), + ("do veins carry blood to the heart or away", ["to"]), + ("who is next in line to be the monarch of england", ["Charles, Prince of Wales"]), + ("what is the capital of france", ["Paris"]), + ("who wrote the novel 1984", ["George Orwell"]), + ("when did world war 2 end", ["1945"]), + ("who was the first president of the united states", ["George Washington"]), + ("what is the largest planet in our solar system", ["Jupiter"]), + ("who painted the mona lisa", ["Leonardo da Vinci"]), + ("what is the speed of light", ["299792458"]), + ("when was the declaration of independence signed", ["1776"]), + ("what is the chemical formula for water", ["H2O"]), + ("who discovered penicillin", ["Alexander Fleming"]), + ("what is the tallest mountain in the world", ["Mount Everest"]), + ("how many continents are there", ["7"]), + ("what is the currency of japan", ["yen"]), + ("who invented the telephone", ["Alexander Graham Bell"]), + ("what is the longest river in the world", ["Nile"]), + ("when did the berlin wall fall", ["1989"]), + ("what is the smallest country in the world", ["Vatican City"]), + ("who was albert einstein", ["physicist"]), + ("what is the boiling point of water", ["100"]), + ("how many states in the us", ["50"]), + ("what language is spoken in brazil", ["Portuguese"]), + ("what is the largest ocean", ["Pacific"]), + ("when did humans first land on the moon", ["1969"]), + ("what is the main ingredient in guacamole", ["avocado"]), + ("who was cleopatra", ["Egyptian queen"]), + ("what is the hardest natural substance", ["diamond"]), + ("how many bones in the human body", ["206"]), + ("what is the capital of australia", ["Canberra"]), + ("who wrote romeo and juliet", ["William Shakespeare"]), + ("what is the largest desert in the world", ["Sahara"]), + ("what is the freezing point of water", ["0"]), + ("who was the first person in space", ["Yuri Gagarin"]), + ("how many planets are there in the solar system", ["8"]), + ("what is the capital of china", ["Beijing"]), + ("who was marie curie", ["physicist"]), + ("what is the largest country in the world by area", ["Russia"]), + ("what is the number 1 sport in the usa", ["American football"]), +] + +prompt_template = ( + 'Answer the given question. ' + 'You must conduct reasoning inside and first every time you get new information. ' + 'After reasoning, if you find you lack some knowledge, you can call a search engine by ' + ' query and it will return the top searched results between ' + ' and . You can search as many times as your want. ' + 'If you find no further external knowledge needed, you can directly provide the answer ' + 'inside and , without detailed illustrations. ' + 'For example, Beijing . Question: {question}\n' +) + +# ==================== 知识库 ==================== +print('[1/3] Loading knowledge base...') +corpus = [] +with open('D:/Search-R1/real_data/corpus.jsonl', 'r', encoding='utf-8') as f: + for line in f: + corpus.append(json.loads(line)) +extra_docs = [ + '{"id":"45","contents":"\\"Death Penalty Statistics\\"\\nThere are approximately 2,718 inmates on death row in the US."}', + '{"id":"46","contents":"\\"Veins and Blood Flow\\"\\nVeins carry blood toward the heart."}', + '{"id":"47","contents":"\\"British Monarchy\\"\\nCharles, Prince of Wales is heir to the throne."}', + '{"id":"48","contents":"\\"George Orwell 1984\\"\\nGeorge Orwell wrote 1984 in 1949."}', + '{"id":"49","contents":"\\"Speed of Light\\"\\nThe speed of light is 299,792,458 m/s."}', + '{"id":"50","contents":"\\"Declaration of Independence\\"\\nAdopted on July 4, 1776."}', + '{"id":"51","contents":"\\"Penicillin Discovery\\"\\nAlexander Fleming discovered penicillin in 1928."}', + '{"id":"52","contents":"\\"Highest Mountain\\"\\nMount Everest is the tallest mountain at 8,849 meters."}', + '{"id":"53","contents":"\\"American Football\\"\\nAmerican football is the number 1 sport in the USA."}', + '{"id":"54","contents":"\\"Cleopatra\\"\\nCleopatra was an Egyptian queen and pharaoh."}', + '{"id":"55","contents":"\\"Einstein\\"\\nAlbert Einstein was a theoretical physicist."}', + '{"id":"56","contents":"\\"Yuri Gagarin\\"\\nYuri Gagarin was the first person in space in 1961."}', + '{"id":"57","contents":"\\"Marie Curie\\"\\nMarie Curie was a physicist and chemist."}', + '{"id":"58","contents":"\\"Diamond\\"\\nDiamond is the hardest natural substance on Earth."}', + '{"id":"59","contents":"\\"Human Bones\\"\\nThe human body has 206 bones."}', + '{"id":"60","contents":"\\"Canberra\\"\\nCanberra is the capital of Australia."}', + '{"id":"61","contents":"\\"Sahara Desert\\"\\nThe Sahara is the largest desert in the world."}', + '{"id":"62","contents":"\\"Freezing Point\\"\\nThe freezing point of water is 0 degrees Celsius."}', + '{"id":"64","contents":"\\"H2O\\"\\nThe chemical formula for water is H2O."}', + '{"id":"65","contents":"\\"Continents\\"\\nThere are 7 continents on Earth."}', + '{"id":"66","contents":"\\"Water Boiling Point\\"\\nThe boiling point of water is 100 degrees Celsius."}', + '{"id":"67","contents":"\\"Planet Jupiter\\"\\nJupiter is the largest planet in our solar system."}', + '{"id":"68","contents":"\\"Mona Lisa\\"\\nThe Mona Lisa was painted by Leonardo da Vinci."}', + '{"id":"69","contents":"\\"US States\\"\\nThere are 50 states in the United States."}', + '{"id":"70","contents":"\\"Nile River\\"\\nThe Nile is the longest river in the world."}', + '{"id":"71","contents":"\\"Berlin Wall\\"\\nThe Berlin Wall fell in 1989."}', + '{"id":"72","contents":"\\"Vatican City\\"\\nVatican City is the smallest country in the world."}', + '{"id":"73","contents":"\\"Telephone\\"\\nAlexander Graham Bell invented the telephone."}', + '{"id":"74","contents":"\\"Brazil Language\\"\\nThe language spoken in Brazil is Portuguese."}', + '{"id":"75","contents":"\\"Moon Landing\\"\\nHumans first landed on the moon in 1969 (Apollo 11)."}', +] +for doc_str in extra_docs: + corpus.append(json.loads(doc_str)) +print(f' {len(corpus)} documents') + +# ==================== 内联检索 ==================== +def simple_search(query, topk=3): + words = re.findall(r'\w+', query.lower()) + scored = [] + for doc in corpus: + content = doc["contents"].lower() + score = sum(1 for w in words if w in content) + scored.append((score, doc)) + scored.sort(key=lambda x: -x[0]) + passages = '' + for idx, (s, d) in enumerate(scored[:topk]): + content = d["contents"] + title = content.split('\n')[0].strip('"') + text = '\n'.join(content.split('\n')[1:]) + passages += f'Doc {idx+1}(Title: {title}) {text}\n' + return passages + +# ==================== 加载模型 ==================== +print('[2/3] Loading Qwen2.5-3B...') +gc.collect() +torch.cuda.empty_cache() + +tokenizer = transformers.AutoTokenizer.from_pretrained(MODEL_PATH) +if tokenizer.pad_token is None: + tokenizer.pad_token = tokenizer.eos_token + +model = transformers.AutoModelForCausalLM.from_pretrained( + MODEL_PATH, dtype=torch.float16, device_map='cuda:0', low_cpu_mem_usage=True) +model.eval() +print(f' GPU: {torch.cuda.memory_allocated()/1024**3:.2f}GB') + +# ==================== 工具函数 ==================== +def build_prompt(question): + p = prompt_template.format(question=question) + if tokenizer.chat_template: + messages = [ + {"role": "system", "content": "You are a helpful assistant. Always put your final answer inside and tags. Use and for reasoning. Use and for search queries."}, + {"role": "user", "content": p} + ] + p = tokenizer.apply_chat_template(messages, add_generation_prompt=True, tokenize=False) + return p + +def normalize_answer(ans): + ans = ans.lower().strip() + ans = re.sub(r'[^\w\s]', '', ans) + ans = re.sub(r'\b(a|an|the)\b', ' ', ans) + return ' '.join(ans.split()) + +def em_reward(answer, golden_answers): + if answer is None: + return 0.0 + norm = normalize_answer(answer) + for g in golden_answers: + if norm == normalize_answer(g): + return 1.0 + return 0.0 + +def format_reward_score(text): + """格式奖励: think(0.1) + search(0.1) + answer(0.1)""" + r = 0.0 + if text.count('') >= 1 and text.count('') == text.count(''): + r += 0.1 + if re.search(r'.*?', text, re.DOTALL): + r += 0.1 + if re.search(r'.*?', text, re.DOTALL): + r += 0.1 + return min(r, 0.3) + +def has_format_tags(text): + ht = text.count('') >= 1 and text.count('') == text.count('') + hs = bool(re.search(r'.*?', text, re.DOTALL)) + ha = bool(re.search(r'.*?', text, re.DOTALL)) + return ht, hs, ha + +def extract_answer(text): + m = re.search(r'(.*?)', text, re.DOTALL) + if m: + return m.group(1).strip() + return None + +def generate_response(question, temperature=0.7): + prompt = build_prompt(question) + full_text = prompt + search_count = 0 + for turn in range(3): + input_ids = tokenizer.encode(full_text, return_tensors='pt').to(model.device) + if input_ids.shape[1] > 4000: + input_ids = input_ids[:, -4000:] + attention_mask = torch.ones_like(input_ids) + try: + with torch.no_grad(): + outputs = model.generate(input_ids, attention_mask=attention_mask, + max_new_tokens=MAX_NEW_TOKENS, do_sample=(temperature > 0), + temperature=temperature if temperature > 0 else None, + pad_token_id=tokenizer.eos_token_id, + top_p=0.95 if temperature > 0 else None) + except Exception as e: + return None, '', search_count, full_text + output_text = tokenizer.decode(outputs[0][input_ids.shape[1]:], skip_special_tokens=True) + full_text += output_text + search_match = re.search(r'(.*?)', output_text, re.DOTALL) + if search_match and turn < 2: + search_count += 1 + passages = simple_search(search_match.group(1).strip()) + full_text += f'\n\n{passages}\n\n' + elif re.search(r'(.*?)', output_text, re.DOTALL): + break + answer = extract_answer(full_text[len(prompt):]) + return answer, full_text[len(prompt):], search_count, full_text + +def run_experiment(mode, n_samples_per_q, log_path): + """运行实验:对每个问题采样N次,分别用两种奖励评估""" + print(f'\n{"="*60}') + print(f'Experiment: {mode.upper()} | {n_samples_per_q} samples/question') + if mode == 'format': + print('Selection: EM(0.7) + think(0.1) + search(0.1) + answer(0.1)') + else: + print('Selection: EM only (0 or 1)') + print('='*60) + + results = [] + all_format_rewards = [] + all_search_counts = [] + all_tag_usage = [] + + for qi, (question, golden) in enumerate(nq_data): + samples = [] + for _ in range(n_samples_per_q): + temp = random.choice([0.5, 0.7, 0.9]) + answer, output_text, sc, full_text = generate_response(question, temperature=temp) + er = em_reward(answer, golden) + fr = format_reward_score(output_text) + ht, hs, ha = has_format_tags(output_text) + + if mode == 'format': + total_r = 0.7 * er + fr + else: + total_r = er + + samples.append({ + 'answer': answer, 'output': output_text, 'em': er, + 'format_reward': fr, 'search_count': sc, + 'total_reward': total_r, 'tags': (ht, hs, ha) + }) + + # 选择最佳样本 + best = max(samples, key=lambda x: x['total_reward']) + results.append({ + 'question': question, 'golden': golden, + 'best_answer': best['answer'], 'best_em': best['em'], + 'best_format_reward': best['format_reward'], + 'best_search_count': best['search_count'], + 'all_samples': samples + }) + all_format_rewards.append(best['format_reward']) + all_search_counts.append(best['search_count']) + all_tag_usage.append(best['tags']) + + if (qi + 1) % 5 == 0 or qi == 0: + em_count = sum(1 for r in results if r['best_em'] > 0) + avg_fmt = sum(all_format_rewards) / len(all_format_rewards) + avg_sc = sum(all_search_counts) / len(all_search_counts) + n_think = sum(1 for t in all_tag_usage if t[0]) / len(all_tag_usage) + n_search = sum(1 for t in all_tag_usage if t[1]) / len(all_tag_usage) + n_answer = sum(1 for t in all_tag_usage if t[2]) / len(all_tag_usage) + print(f' Q{qi+1}/{len(nq_data)} | EM={em_count}/{len(results)} ({em_count/len(results):.0%}) | Fmt={avg_fmt:.3f} | Search={avg_sc:.1f} | Tags=[T:{n_think:.0%} S:{n_search:.0%} A:{n_answer:.0%}]') + print(f' Best: {best["answer"]} (golden: {golden[0][:20]})') + + # 汇总 + em_count = sum(1 for r in results if r['best_em'] > 0) + summary = { + 'mode': mode, + 'total_questions': len(nq_data), + 'correct': em_count, + 'em_accuracy': em_count / len(nq_data), + 'avg_format_reward': sum(all_format_rewards) / len(all_format_rewards), + 'avg_search_count': sum(all_search_counts) / len(all_search_counts), + 'tag_usage': { + 'think': sum(1 for t in all_tag_usage if t[0]) / len(all_tag_usage), + 'search': sum(1 for t in all_tag_usage if t[1]) / len(all_tag_usage), + 'answer': sum(1 for t in all_tag_usage if t[2]) / len(all_tag_usage), + } + } + + log_data = {'summary': summary, 'results': []} + for r in results: + log_data['results'].append({ + 'question': r['question'], 'golden': r['golden'][0], + 'best_answer': r['best_answer'], 'best_em': r['best_em'], + 'best_format_reward': r['best_format_reward'], + 'best_search_count': r['best_search_count'], + 'n_samples': n_samples_per_q, + }) + with open(log_path, 'w') as f: + json.dump(log_data, f, indent=2, ensure_ascii=False) + + print(f'\n === {mode.upper()} RESULTS ===') + print(f' EM Accuracy: {em_count}/{len(nq_data)} ({summary["em_accuracy"]:.0%})') + print(f' Avg Format Reward: {summary["avg_format_reward"]:.3f}') + print(f' Avg Search Count: {summary["avg_search_count"]:.2f}') + print(f' Tag Usage: Think={summary["tag_usage"]["think"]:.0%} Search={summary["tag_usage"]["search"]:.0%} Answer={summary["tag_usage"]["answer"]:.0%}') + return summary + +# ==================== 主流程 ==================== +print('[3/3] Running experiments...') + +print('\n>>> Phase 1: Baseline (EM reward) <<<') +baseline = run_experiment('baseline', N_AGENT, 'D:/Search-R1/baseline_results.json') + +print('\n>>> Phase 2: Format Reward <<<') +fmt_result = run_experiment('format', N_AGENT, 'D:/Search-R1/format_reward_results.json') + +# ==================== 对比总结 ==================== +print('\n\n' + '='*70) +print('FINAL COMPARISON: Baseline(EM) vs FormatReward') +print('='*70) +print(f'{"Metric":<35} {"Baseline(EM)":<15} {"FormatReward":<15} {"Diff":<10}') +print('-'*70) +for key, label in [('em_accuracy', 'EM Accuracy'), ('avg_format_reward', 'Avg Format Reward'), + ('avg_search_count', 'Avg Search Count')]: + b = baseline[key] + f = fmt_result[key] + diff = f - b + sign = '+' if diff > 0 else '' + if 'accuracy' in key: + print(f'{label:<35} {b:<15.0%} {f:<15.0%} {sign}{diff:.0%}') + else: + print(f'{label:<35} {b:<15.3f} {f:<15.3f} {sign}{diff:.3f}') + +print('\nFormat Tag Usage:') +for tag in ['think', 'search', 'answer']: + b = baseline['tag_usage'][tag] + f = fmt_result['tag_usage'][tag] + diff = f - b + sign = '+' if diff > 0 else '' + print(f' {tag:<10} Baseline: {b:.0%} | Format: {f:.0%} | Diff: {sign}{diff:.0%}') +print('='*70) + +comparison = {'baseline': baseline, 'format_reward': fmt_result} +with open('D:/Search-R1/comparison_results.json', 'w') as f: + json.dump(comparison, f, indent=2, ensure_ascii=False) +print('\nResults saved to D:/Search-R1/comparison_results.json') + +del model +gc.collect() +torch.cuda.empty_cache()