finsight / training /evaluate.py
Maggei's picture
Add before/after eval: serve LoRA adapter + base vs finetune comparison
36ac7d4
Raw
History Blame Contribute Delete
9.1 kB
"""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 = ["公司", "行业", "大盘", "中国", "国际", "经济", "政策", "期货",
"债券", "房地产", "外汇", "虚拟货币", "新冠", "能源", "政治"]
# ---------------- Ollama ----------------
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) # A=ft, B=base
v2 = llm_judge(judge, q, b, f) # A=base, B=ft (互换)
# 统计:把两轮折算到 ft 视角
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()