- 全链路:多源JD接入/清洗交叉验证/结构化抽取(星火+离线双通道)/时序知识图谱/新岗位发现/演化分析/人岗匹配/学习路径 - WebUI 五视图:驾驶舱/图谱工作台/岗位演化/匹配(单人+批量)/学习路径 - 设置中心(config.json)/Token鉴权/SQLite状态库/语义向量通道(BGE-M3本地或联网API) - 71 个 pytest 用例;数据目录仅保留结构(.gitkeep),数据由 scripts/generate_dataset.py 复现
252 lines
11 KiB
Python
252 lines
11 KiB
Python
"""指标复现脚本:真实“从文本抽取 → 与 gold 比对”流程,不硬编码任何答案。
|
||
|
||
三项硬指标(合成测试集):
|
||
1. JD 解析准确率 —— 对 data/testset/jd_testset.jsonl 重新抽取,与 gold_answers.json
|
||
算字段级 P/R/F1(必备技能/加分技能/职责/场景 micro-F1 + 岗位名称准确率);
|
||
2. 简历提取准确率 —— 对 data/resumes/*.txt 解析技能,与 gold.json 算 F1;
|
||
3. 人岗匹配准确率 —— 对 data/testset/match_gold.json 标注对跑匹配引擎,
|
||
判定“命中预期匹配/不匹配”的比例。
|
||
|
||
两个真实数据维度(data/real/,与合成集分开报告、不混算):
|
||
4. 真实 JD(AutoWeakS 初标+抽检 silver gold)—— 对 jd_sample.jsonl 重新抽取,
|
||
与 gold_answers.json 算技能/场景字段级 F1;
|
||
5. 真实简历(AIStudio CC0)—— 对 50 份简历解析,姓名/电话命中率 + 技能 F1。
|
||
|
||
输出 reports/metrics.json 与控制台报告。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
from app.extract.offline import OfflineExtractor # noqa: E402
|
||
from app.extract.base import SkillMatcher, load_lexicon # noqa: E402
|
||
from app.graph.tkg import TemporalKG # noqa: E402
|
||
from app.ingest.loader import load_jd_file # noqa: E402
|
||
from app.match.engine import MatchEngine # noqa: E402
|
||
from app.match.resume import parse_resume # noqa: E402
|
||
from app.models import JDRecord # noqa: E402
|
||
|
||
|
||
def prf(tp: int, pred: int, gold: int) -> tuple[float, float, float]:
|
||
p = tp / pred if pred else 0.0
|
||
r = tp / gold if gold else 0.0
|
||
f1 = 2 * p * r / (p + r) if p + r else 0.0
|
||
return round(p, 4), round(r, 4), round(f1, 4)
|
||
|
||
|
||
def eval_jd_extraction() -> dict:
|
||
"""JD 解析:从文本重新抽取,与标准答案做字段级比对。"""
|
||
records, errors = load_jd_file(ROOT / "data" / "testset" / "jd_testset.jsonl")
|
||
gold = json.loads((ROOT / "data" / "testset" / "gold_answers.json").read_text(encoding="utf-8"))
|
||
extractor = OfflineExtractor(load_lexicon())
|
||
|
||
fields = ["required_skills", "preferred_skills", "responsibilities", "scenarios"]
|
||
tp = {f: 0 for f in fields}
|
||
pred = {f: 0 for f in fields}
|
||
gold_n = {f: 0 for f in fields}
|
||
title_hit = 0
|
||
for rec in records:
|
||
g = gold[rec.jid]
|
||
ext = extractor.extract(rec)
|
||
title_hit += int(ext.job_title == g["job_title"])
|
||
for f in fields:
|
||
got, want = set(getattr(ext, f)), set(g[f])
|
||
tp[f] += len(got & want)
|
||
pred[f] += len(got)
|
||
gold_n[f] += len(want)
|
||
|
||
detail = {}
|
||
f1s = []
|
||
for f in fields:
|
||
p, r, f1 = prf(tp[f], pred[f], gold_n[f])
|
||
detail[f] = {"precision": p, "recall": r, "f1": f1}
|
||
f1s.append(f1)
|
||
title_acc = round(title_hit / len(records), 4)
|
||
# 综合准确率:技能字段权重更高(技能是岗位图谱的核心)
|
||
accuracy = round(0.6 * (f1s[0] + f1s[1]) / 2 + 0.2 * f1s[2] + 0.1 * f1s[3] + 0.1 * title_acc, 4)
|
||
return {"accuracy": accuracy, "title_accuracy": title_acc, "fields": detail,
|
||
"samples": len(records), "bad_lines": len(errors)}
|
||
|
||
|
||
def eval_resume() -> dict:
|
||
"""简历技能提取准确率(逐份 F1 取平均)。"""
|
||
gold = json.loads((ROOT / "data" / "resumes" / "gold.json").read_text(encoding="utf-8"))
|
||
matcher = SkillMatcher(load_lexicon())
|
||
f1s, detail = [], {}
|
||
for name, g in gold.items():
|
||
text = (ROOT / "data" / "resumes" / name).read_text(encoding="utf-8")
|
||
profile = parse_resume(text, matcher)
|
||
got, want = set(profile.skills), set(g["skills"])
|
||
p, r, f1 = prf(len(got & want), len(got), len(want))
|
||
f1s.append(f1)
|
||
detail[name] = {"precision": p, "recall": r, "f1": f1,
|
||
"extra": sorted(got - want), "missing": sorted(want - got)}
|
||
return {"accuracy": round(sum(f1s) / len(f1s), 4), "detail": detail}
|
||
|
||
|
||
def eval_match() -> dict:
|
||
"""人岗匹配准确率:命中预期匹配/不匹配。"""
|
||
graph_path = ROOT / "data" / "graph.json"
|
||
if not graph_path.exists():
|
||
from app.services.pipeline import run_pipeline
|
||
run_pipeline()
|
||
kg = TemporalKG.load(graph_path)
|
||
by_title = {j.title: j for j in kg.jobs.values()}
|
||
pairs = json.loads((ROOT / "data" / "testset" / "match_gold.json").read_text(encoding="utf-8"))
|
||
engine = MatchEngine(load_lexicon())
|
||
from app.models import ResumeProfile
|
||
hit, detail = 0, []
|
||
for pair in pairs:
|
||
job = by_title.get(pair["job_title"])
|
||
if job is None:
|
||
continue
|
||
profile = ResumeProfile(skills=pair["skills"], years=pair["years"],
|
||
scenarios=pair["scenarios"])
|
||
result = engine.match(profile, job)
|
||
ok = result.is_match == pair["expected_match"]
|
||
hit += int(ok)
|
||
detail.append({"job": pair["job_title"], "expected": pair["expected_match"],
|
||
"score": result.total_score, "ok": ok})
|
||
return {"accuracy": round(hit / len(pairs), 4), "pairs": len(pairs), "detail": detail}
|
||
|
||
|
||
def eval_real_jd() -> dict:
|
||
"""真实 JD(AutoWeakS):从文本重新抽取,与 silver gold(初标+抽检)字段级比对。
|
||
|
||
真实 JD 是自由文本、无编号版面,gold 的 responsibilities/preferred_skills 为空,
|
||
口径 = 全文词表技能(required_skills)+ 应用场景 + 启发式岗位名,见 data/real/README.md。
|
||
"""
|
||
records, errors = load_jd_file(ROOT / "data" / "real" / "autoweaks_jd" / "jd_sample.jsonl")
|
||
gold = json.loads(
|
||
(ROOT / "data" / "real" / "autoweaks_jd" / "gold_answers.json").read_text(encoding="utf-8"))
|
||
extractor = OfflineExtractor(load_lexicon())
|
||
|
||
fields = ["required_skills", "preferred_skills", "scenarios"]
|
||
tp = {f: 0 for f in fields}
|
||
pred = {f: 0 for f in fields}
|
||
gold_n = {f: 0 for f in fields}
|
||
title_hit = 0
|
||
for rec in records:
|
||
g = gold[rec.jid]
|
||
ext = extractor.extract(rec)
|
||
title_hit += int(ext.job_title == g["job_title"])
|
||
for f in fields:
|
||
got, want = set(getattr(ext, f)), set(g[f])
|
||
tp[f] += len(got & want)
|
||
pred[f] += len(got)
|
||
gold_n[f] += len(want)
|
||
|
||
detail, active_f1 = {}, []
|
||
for f in fields:
|
||
if not gold_n[f] and not pred[f]:
|
||
detail[f] = None # 该字段在真实语料中无标注口径,不参与
|
||
continue
|
||
p, r, f1 = prf(tp[f], pred[f], gold_n[f])
|
||
detail[f] = {"precision": p, "recall": r, "f1": f1}
|
||
active_f1.append((f, f1))
|
||
weights = {"required_skills": 0.7, "preferred_skills": 0.2, "scenarios": 0.1}
|
||
wsum = sum(weights[f] for f, _ in active_f1) or 1.0
|
||
accuracy = round(sum(weights[f] * f1 for f, f1 in active_f1) / wsum, 4)
|
||
return {"accuracy": accuracy,
|
||
"title_accuracy": round(title_hit / len(records), 4),
|
||
"fields": detail, "samples": len(records), "bad_lines": len(errors)}
|
||
|
||
|
||
def eval_real_resume() -> dict:
|
||
"""真实简历(AIStudio CC0):姓名/电话命中率(脱敏形态比对)+ 技能 F1。"""
|
||
gold = json.loads(
|
||
(ROOT / "data" / "real" / "aistudio_resumes" / "gold.json").read_text(encoding="utf-8"))
|
||
matcher = SkillMatcher(load_lexicon())
|
||
f1s, name_hit, phone_hit, phone_total = [], 0, 0, 0
|
||
empty_skills = 0
|
||
for name, g in gold.items():
|
||
text = (ROOT / "data" / "real" / "aistudio_resumes" / name).read_text(encoding="utf-8")
|
||
profile = parse_resume(text, matcher)
|
||
# gold 存明文,解析结果是脱敏形态(姓氏+* / 138****5678),按脱敏形态比对
|
||
want_name = g["name"][0] + "*" * (len(g["name"]) - 1)
|
||
name_hit += int(profile.name == want_name)
|
||
if g.get("phone"):
|
||
phone_total += 1
|
||
want_phone = g["phone"][:3] + "****" + g["phone"][-2:]
|
||
phone_hit += int(profile.phone == want_phone)
|
||
got, want = set(profile.skills), set(g["skills"])
|
||
if not got and not want:
|
||
empty_skills += 1
|
||
f1s.append(1.0) # 双方均无词表技能 = 完全一致(真实简历大量非技术内容)
|
||
continue
|
||
_, _, f1 = prf(len(got & want), len(got), len(want))
|
||
f1s.append(f1)
|
||
n = len(gold)
|
||
return {"accuracy": round(sum(f1s) / n, 4),
|
||
"name_accuracy": round(name_hit / n, 4),
|
||
"phone_accuracy": round(phone_hit / phone_total, 4) if phone_total else 0.0,
|
||
"samples": n, "phone_samples": phone_total,
|
||
"empty_skill_resumes": empty_skills}
|
||
|
||
|
||
def main() -> None:
|
||
jd = eval_jd_extraction()
|
||
resume = eval_resume()
|
||
match = eval_match()
|
||
real_jd = eval_real_jd()
|
||
real_resume = eval_real_resume()
|
||
metrics = {
|
||
"jd_extraction_accuracy": jd["accuracy"],
|
||
"resume_extraction_accuracy": resume["accuracy"],
|
||
"match_accuracy": match["accuracy"],
|
||
"jd_detail": jd,
|
||
"resume_detail": resume["detail"],
|
||
"match_detail": match["detail"],
|
||
"real_jd": real_jd,
|
||
"real_resume": real_resume,
|
||
}
|
||
out = ROOT / "reports"
|
||
out.mkdir(exist_ok=True)
|
||
(out / "metrics.json").write_text(
|
||
json.dumps(metrics, ensure_ascii=False, indent=1), encoding="utf-8")
|
||
|
||
print("=" * 56)
|
||
print("CareerNexus 指标复现报告")
|
||
print("=" * 56)
|
||
print(f"JD 解析准确率 : {jd['accuracy']:.2%} (样本 {jd['samples']} 条)")
|
||
for f, d in jd["fields"].items():
|
||
print(f" - {f:<18} P={d['precision']:.2%} R={d['recall']:.2%} F1={d['f1']:.2%}")
|
||
print(f" - 岗位名称准确率 {jd['title_accuracy']:.2%}")
|
||
print(f"简历提取准确率 : {resume['accuracy']:.2%}")
|
||
for name, d in resume["detail"].items():
|
||
print(f" - {name:<22} F1={d['f1']:.2%}")
|
||
print(f"人岗匹配准确率 : {match['accuracy']:.2%} (标注对 {match['pairs']} 对)")
|
||
print("-" * 56)
|
||
print("真实数据维度(data/real/,与合成集分开报告)")
|
||
print(f"真实 JD(AutoWeakS 初标+抽检): 综合 F1 {real_jd['accuracy']:.2%}"
|
||
f" (样本 {real_jd['samples']} 条)")
|
||
for f, d in real_jd["fields"].items():
|
||
if d is None:
|
||
print(f" - {f:<18} (真实语料无标注口径,不参与)")
|
||
else:
|
||
print(f" - {f:<18} P={d['precision']:.2%} R={d['recall']:.2%} F1={d['f1']:.2%}")
|
||
print(f" - 岗位名称准确率 {real_jd['title_accuracy']:.2%} (启发式标注)")
|
||
print(f"真实简历(AIStudio CC0) : 技能 F1 {real_resume['accuracy']:.2%}"
|
||
f" 姓名命中 {real_resume['name_accuracy']:.2%}"
|
||
f" 电话命中 {real_resume['phone_accuracy']:.2%} ({real_resume['samples']} 份)")
|
||
print(f"\n指标已写入 {out / 'metrics.json'}")
|
||
target = 0.90
|
||
for name, v in [("JD解析", jd["accuracy"]), ("简历提取", resume["accuracy"]),
|
||
("人岗匹配", match["accuracy"])]:
|
||
status = "达标" if v >= target else "未达标"
|
||
print(f"[{status}] {name} {v:.2%} (目标 ≥{target:.0%})")
|
||
real_targets = [("真实JD技能F1", real_jd["fields"]["required_skills"]["f1"], 0.80),
|
||
("真实姓名命中", real_resume["name_accuracy"], 1.00)]
|
||
for name, v, t in real_targets:
|
||
status = "达标" if v >= t else "未达标"
|
||
print(f"[{status}] {name} {v:.2%} (目标 ≥{t:.0%})")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|