| """FinSight 微调 before/after 评估 harness。 |
| |
| 对同一份分层抽样的 held-out eval 集,用**完全相同**的 prompt 和解码参数, |
| 分别跑 base 模型与微调模型,按 Agent 用任务匹配的指标打分。 |
| |
| 三层指标(为何这么分见 README / 对话说明): |
| 1) 客观标签指标 —— sentiment 三分类准确率 + macro-F1;topic/FINNL 类别准确率 |
| 2) ROUGE-L(字级)—— summary / event / topic-FINRE / qa 的 n-gram 重合(含格式) |
| 3) LLM-as-judge —— qa 开放问答的成对胜率(裁判换一个模型 + 位置互换去偏) |
| |
| 用法(用 .venv,只需 requests+jieba,不依赖 torch): |
| .venv/bin/python training/evaluate.py --n 120 \ |
| --base qwen2.5:7b-instruct --ft finsight-qwen --judge qwen3:8b |
| """ |
| import argparse, json, random, re, time, pathlib, collections |
| import urllib.request |
|
|
| ROOT = pathlib.Path(__file__).resolve().parent.parent |
| EVAL = ROOT / "data" / "processed" / "finsight_eval.jsonl" |
| OLLAMA = "http://localhost:11434" |
|
|
| SENTI = ["积极", "消极", "中性"] |
| FINNL_CATS = ["公司", "行业", "大盘", "中国", "国际", "经济", "政策", "期货", |
| "债券", "房地产", "外汇", "虚拟货币", "新冠", "能源", "政治"] |
|
|
|
|
| |
| def ollama_chat(model, user, num_predict=512, think=None): |
| body = { |
| "model": model, |
| "messages": [{"role": "user", "content": user}], |
| "stream": False, |
| "options": {"temperature": 0, "num_predict": num_predict, "seed": 42}, |
| } |
| if think is not None: |
| body["think"] = think |
| req = urllib.request.Request( |
| f"{OLLAMA}/api/chat", |
| data=json.dumps(body).encode(), |
| headers={"Content-Type": "application/json"}, |
| ) |
| for _ in range(3): |
| try: |
| r = json.loads(urllib.request.urlopen(req, timeout=300).read()) |
| return r["message"]["content"].strip() |
| except Exception as e: |
| last = e |
| time.sleep(2) |
| raise last |
|
|
|
|
| def prompt_of(d): |
| """对齐训练时的 alpaca→qwen 拼接:instruction + 换行 + input。""" |
| return d["instruction"] + (("\n" + d["input"]) if d.get("input") else "") |
|
|
|
|
| |
| def extract_senti(text): |
| |
| hits = [(text.rfind(w), w) for w in SENTI if w in text] |
| return max(hits)[1] if hits else None |
|
|
|
|
| def extract_finnl(text): |
| hits = [(text.rfind(c), c) for c in FINNL_CATS if c in text] |
| return max(hits)[1] if hits else None |
|
|
|
|
| def lcs(a, b): |
| m, n = len(a), len(b) |
| dp = [0] * (n + 1) |
| for i in range(1, m + 1): |
| prev = 0 |
| for j in range(1, n + 1): |
| tmp = dp[j] |
| dp[j] = prev + 1 if a[i - 1] == b[j - 1] else max(dp[j], dp[j - 1]) |
| prev = tmp |
| return dp[n] |
|
|
|
|
| def rougeL(pred, gold): |
| """字级 ROUGE-L F1(中文标准做法,去标点空白)。""" |
| clean = lambda s: re.sub(r"[\s,。、;:!?,. ]", "", s) |
| p, g = clean(pred), clean(gold) |
| if not p or not g: |
| return 0.0 |
| l = lcs(p, g) |
| prec, rec = l / len(p), l / len(g) |
| return 0.0 if prec + rec == 0 else 2 * prec * rec / (prec + rec) |
|
|
|
|
| def macro_f1(pairs, labels): |
| """pairs: [(gold, pred)]; 对每个标签算 F1 再平均。""" |
| f1s = [] |
| for lab in labels: |
| tp = sum(1 for g, p in pairs if g == lab and p == lab) |
| fp = sum(1 for g, p in pairs if g != lab and p == lab) |
| fn = sum(1 for g, p in pairs if g == lab and p != lab) |
| prec = tp / (tp + fp) if tp + fp else 0 |
| rec = tp / (tp + fn) if tp + fn else 0 |
| f1s.append(2 * prec * rec / (prec + rec) if prec + rec else 0) |
| return sum(f1s) / len(f1s) |
|
|
|
|
| def llm_judge(judge, question, ans_a, ans_b): |
| """成对评判,返回 'A'/'B'/'tie'。调用方负责位置互换去偏。""" |
| sys = ( |
| "你是金融问答评审。根据【问题】判断哪个回答更准确、专业、完整。" |
| "只输出一个字母:A 表示回答A更好,B 表示回答B更好,T 表示二者相当。不要解释。" |
| ) |
| user = f"【问题】{question}\n\n【回答A】{ans_a}\n\n【回答B】{ans_b}\n\n更好的是(A/B/T):" |
| out = ollama_chat(judge, sys + "\n\n" + user, num_predict=4, think=False).upper() |
| if "A" in out and "B" not in out: |
| return "A" |
| if "B" in out and "A" not in out: |
| return "B" |
| return "tie" |
|
|
|
|
| |
| def sample(n, seed=42): |
| by = collections.defaultdict(list) |
| for line in open(EVAL): |
| d = json.loads(line) |
| by[d["agent"]].append(d) |
| rng = random.Random(seed) |
| out = [] |
| for agent, rows in by.items(): |
| rng.shuffle(rows) |
| out.extend(rows[:n]) |
| return out |
|
|
|
|
| |
| def generate(model, data): |
| preds = [] |
| for i, d in enumerate(data): |
| np_ = 24 if d["agent"] in ("sentiment", "topic") else 512 |
| preds.append(ollama_chat(model, prompt_of(d), num_predict=np_)) |
| if (i + 1) % 50 == 0: |
| print(f" {model}: {i+1}/{len(data)}") |
| return preds |
|
|
|
|
| def score(data, preds): |
| """返回 per-agent 指标 dict。""" |
| by = collections.defaultdict(list) |
| for d, p in zip(data, preds): |
| by[d["agent"]].append((d, p)) |
| res = {} |
| for agent, rows in by.items(): |
| if agent == "sentiment": |
| pairs = [(d["output"], extract_senti(p)) for d, p in rows] |
| acc = sum(1 for g, p in pairs if g == p) / len(pairs) |
| res[agent] = {"n": len(pairs), "accuracy": round(acc, 4), |
| "macro_f1": round(macro_f1(pairs, SENTI), 4), |
| "parse_fail": sum(1 for _, p in pairs if p is None)} |
| else: |
| r = sum(rougeL(p, d["output"]) for d, p in rows) / len(rows) |
| entry = {"n": len(rows), "rougeL": round(r, 4)} |
| if agent == "topic": |
| finnl = [(d, p) for d, p in rows if d["task"] == "FINNL"] |
| if finnl: |
| acc = sum(1 for d, p in finnl |
| if extract_finnl(d["output"]) == extract_finnl(p)) / len(finnl) |
| entry["finnl_acc"] = round(acc, 4) |
| entry["finnl_n"] = len(finnl) |
| res[agent] = entry |
| return res |
|
|
|
|
| def run_judge(judge, data, preds_base, preds_ft): |
| """qa 成对胜率,位置互换两轮。返回 {ft_win, base_win, tie, win_rate}。""" |
| qa = [(d, b, f) for d, b, f in zip(data, preds_base, preds_ft) if d["agent"] == "qa"] |
| ft_win = base_win = tie = 0 |
| for d, b, f in qa: |
| q = prompt_of(d) |
| v1 = llm_judge(judge, q, f, b) |
| v2 = llm_judge(judge, q, b, f) |
| |
| for v, ft_is_a in ((v1, True), (v2, False)): |
| if v == "tie": |
| tie += 1 |
| elif (v == "A") == ft_is_a: |
| ft_win += 1 |
| else: |
| base_win += 1 |
| total = ft_win + base_win + tie |
| return {"qa_n": len(qa), "comparisons": total, "ft_win": ft_win, |
| "base_win": base_win, "tie": tie, |
| "ft_win_rate": round(ft_win / total, 4) if total else 0} |
|
|
|
|
| def main(): |
| ap = argparse.ArgumentParser() |
| ap.add_argument("--n", type=int, default=120, help="每个 agent 抽样数") |
| ap.add_argument("--base", default="qwen2.5:7b-instruct") |
| ap.add_argument("--ft", default="finsight-qwen") |
| ap.add_argument("--judge", default="qwen3:8b") |
| ap.add_argument("--out", default=str(ROOT / "training" / "eval_report.json")) |
| args = ap.parse_args() |
|
|
| data = sample(args.n) |
| print(f"抽样 {len(data)} 条 " |
| f"({dict(collections.Counter(d['agent'] for d in data))})") |
|
|
| print(f"\n[1/2] 生成 base 预测: {args.base}") |
| pb = generate(args.base, data) |
| print(f"[2/2] 生成 微调 预测: {args.ft}") |
| pf = generate(args.ft, data) |
|
|
| sb, sf = score(data, pb), score(data, pf) |
| print(f"\n[judge] qa 成对评判: {args.judge}") |
| judge = run_judge(args.judge, data, pb, pf) |
|
|
| report = {"n_per_agent": args.n, "base": args.base, "ft": args.ft, |
| "judge": args.judge, "scores": {"base": sb, "ft": sf}, |
| "qa_judge": judge} |
| pathlib.Path(args.out).write_text(json.dumps(report, ensure_ascii=False, indent=2)) |
|
|
| |
| print("\n" + "=" * 64) |
| print(f"{'agent':<10}{'metric':<14}{'base':>10}{'finetune':>12}{'Δ':>10}") |
| print("-" * 64) |
| for agent in sb: |
| for m in sb[agent]: |
| if m in ("n", "parse_fail", "finnl_n"): |
| continue |
| bv, fv = sb[agent].get(m, 0), sf[agent].get(m, 0) |
| print(f"{agent:<10}{m:<14}{bv:>10.4f}{fv:>12.4f}{fv-bv:>+10.4f}") |
| print("-" * 64) |
| j = judge |
| print(f"qa LLM-judge ft胜率 {j['ft_win_rate']:.1%} " |
| f"(ft赢{j['ft_win']} / base赢{j['base_win']} / 平{j['tie']}, 共{j['comparisons']}次比较)") |
| print("=" * 64) |
| print(f"\n报告已存:{args.out}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|