层3 E1: similarity.py——语义相似度 φ(rouge1 多重集/edit_distance 词级)+ 式(3) k_sem 聚合,21 单测
对应 docs/04 §4 E1。三处对参考实现的替代:edit 吃 str 内部按词切(不再比 token id,回归 tokenizer 无关)、rouge1 集合改多重集(ROUGE-1 标准定义, 数学文本重复词多)、去掉 1e-8 分母平滑(全同串精确得 1)。φ 默认 edit_distance 对齐论文 §5.1。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
"""语义相似度 φ 与 chunk 级聚合 k_sem——论文 §3.2.1 式(3)。
|
||||
|
||||
logit-free 的支点:学生 chunk 对不对,不再比 token 概率(层 2 白盒式(2)),
|
||||
改比"学生 chunk 文本" vs "teacher rollout 文本"的语义相似度。φ 只依赖
|
||||
文本本身,与两侧 tokenizer 无关,teacher 只需能吐文本(任何 API 均可)。
|
||||
|
||||
纯逻辑模块(CLAUDE.md §2):只依赖标准库,可脱离 torch 在本地 CPU 测试。
|
||||
teacher rollout 怎么采出来是层 5 teacher.py 的事,本模块只吃现成字符串。
|
||||
"""
|
||||
|
||||
from collections import Counter
|
||||
|
||||
|
||||
def rouge1(hypothesis: str, reference: str) -> float:
|
||||
"""ROUGE-1 F1(unigram 重叠率),φ 的候选度量之一(论文 §3.2.1)。
|
||||
|
||||
以词为单位(空白切分)统计两串的 unigram 重叠,算 F1。
|
||||
词袋语义:只看"用了哪些词",不看词序——"a b" vs "b a" 得 1.0。
|
||||
|
||||
参数:
|
||||
hypothesis: 学生 chunk 文本。
|
||||
reference: teacher rollout 文本。
|
||||
|
||||
返回:
|
||||
F1 ∈ [0, 1];任一侧无词(空串/纯空白)时为 0.0。
|
||||
|
||||
实现细节:
|
||||
- 差异标注:参考实现(distillation_trainer.py:1670)用 set 去重后求交,
|
||||
会把 "x x x x" vs "x" 判成满分 1.0;此处用 Counter 多重集
|
||||
(ROUGE-1 标准定义),重复词按 min 计数配对,同例只得 0.4。
|
||||
数学推理文本里重复 token(数字、"="、变量名)极常见,去重会失真。
|
||||
- 差异标注:参考实现分母加 1e-8 防零除,代价是全同串 F1≈0.99999998
|
||||
而非精确 1;此处 overlap==0 时提前返回,分母恒正,无需平滑。
|
||||
"""
|
||||
hyp_counts = Counter(hypothesis.split())
|
||||
ref_counts = Counter(reference.split())
|
||||
if not hyp_counts or not ref_counts:
|
||||
return 0.0
|
||||
# 多重集交:每个词按两侧出现次数的 min 配对
|
||||
overlap = sum((hyp_counts & ref_counts).values())
|
||||
if overlap == 0:
|
||||
return 0.0
|
||||
precision = overlap / sum(hyp_counts.values())
|
||||
recall = overlap / sum(ref_counts.values())
|
||||
return 2 * precision * recall / (precision + recall)
|
||||
|
||||
|
||||
def edit_similarity(hypothesis: str, reference: str) -> float:
|
||||
"""归一化编辑相似度 1 − Levenshtein/max(m,n),论文 §5.1 的默认 φ。
|
||||
|
||||
以词为单位(空白切分)算 Levenshtein 距离(插入/删除/替换各计 1),
|
||||
再归一化到 [0, 1] 取反。顺序敏感:"a b" vs "b a" 距离 2,相似度 0——
|
||||
与 rouge1 的词袋语义形成互补。
|
||||
|
||||
参数:
|
||||
hypothesis: 学生 chunk 文本。
|
||||
reference: teacher rollout 文本。
|
||||
|
||||
返回:
|
||||
相似度 ∈ [0, 1];两侧均空为 1.0(零距离),仅一侧空为 0.0(全删/全插)。
|
||||
|
||||
实现细节:
|
||||
- 差异标注:参考实现(distillation_trainer.py:1682)吃 token id 列表,
|
||||
相似度随 tokenizer 切法漂移,违背本层"文本是公共语言"的初衷
|
||||
(docs/04 §2.1 坑①);此处吃 str、内部按词切,与 rouge1 统一口径。
|
||||
- 两行滚动 DP(同参考实现):空间 O(n) 而非 O(m·n)。
|
||||
"""
|
||||
hyp_words = hypothesis.split()
|
||||
ref_words = reference.split()
|
||||
m, n = len(hyp_words), len(ref_words)
|
||||
if m == 0 and n == 0:
|
||||
return 1.0
|
||||
if m == 0 or n == 0:
|
||||
return 0.0
|
||||
# prev[j] = 前一行的 dist(hyp[:i-1], ref[:j]);curr 原地滚动复用
|
||||
prev = list(range(n + 1))
|
||||
curr = [0] * (n + 1)
|
||||
for i in range(1, m + 1):
|
||||
curr[0] = i
|
||||
for j in range(1, n + 1):
|
||||
cost = 0 if hyp_words[i - 1] == ref_words[j - 1] else 1
|
||||
curr[j] = min(
|
||||
prev[j] + 1, # 删除 hyp[i-1]
|
||||
curr[j - 1] + 1, # 插入 ref[j-1]
|
||||
prev[j - 1] + cost, # 替换(相同则免费)
|
||||
)
|
||||
prev, curr = curr, prev
|
||||
return 1.0 - prev[n] / max(m, n)
|
||||
|
||||
|
||||
def phi(hypothesis: str, reference: str, metric: str = "edit_distance") -> float:
|
||||
"""语义相似度 φ(y_c, ŷ_c) ∈ [0, 1],论文 §3.2.1 式(3) 的原子度量。
|
||||
|
||||
参数:
|
||||
hypothesis: 学生 chunk 文本。
|
||||
reference: teacher rollout 文本。
|
||||
metric: "edit_distance"(默认)或 "rouge1"。
|
||||
差异标注:参考实现配置默认 rouge1(config.py:299),与论文 §5.1
|
||||
的 edit_distance 背离;此处从论文。
|
||||
|
||||
返回:
|
||||
相似度 ∈ [0, 1]。
|
||||
"""
|
||||
if metric == "edit_distance":
|
||||
return edit_similarity(hypothesis, reference)
|
||||
if metric == "rouge1":
|
||||
return rouge1(hypothesis, reference)
|
||||
raise ValueError(f"未知相似度度量: {metric!r}(可选 'edit_distance' / 'rouge1')")
|
||||
|
||||
|
||||
def aggregate_similarity(
|
||||
student_chunk: str,
|
||||
teacher_rollouts: list[str],
|
||||
metric: str = "edit_distance",
|
||||
) -> float:
|
||||
"""式(3):k_sem = Σ_{i=1}^{N} φ(y_c, ŷ_c^{(i)}),chunk 的语义匹配计数。
|
||||
|
||||
学生 chunk 与 N 个 teacher rollout 逐一算 φ 后求和。φ 连续,故 k_sem 是
|
||||
[0, N] 上的实数——"软计数":k_sem≈N 意为学生这段与 teacher 高度一致,
|
||||
k_sem≈0 意为 teacher 从不这么写。它是 π̂(式5)里唯一的外部 teacher 信号。
|
||||
|
||||
参数:
|
||||
student_chunk: 学生 chunk 文本(C 个 token 解码所得)。
|
||||
teacher_rollouts: N 段 teacher 续写文本,与学生 chunk 共享同一前缀
|
||||
y_<c(对应关系由"同一前缀现场生成"保证,无需搜索匹配,docs/04 §1)。
|
||||
metric: 传给 phi,默认 "edit_distance"。
|
||||
|
||||
返回:
|
||||
k_sem ∈ [0, N],N = len(teacher_rollouts)。空列表得 0.0(空和)。
|
||||
"""
|
||||
return sum(phi(student_chunk, r, metric) for r in teacher_rollouts)
|
||||
@@ -0,0 +1,142 @@
|
||||
"""similarity.py 单测——docs/04 §5.1:手构字符串钉死 φ 与 k_sem(式3)。
|
||||
|
||||
全部本地 CPU、纯标准库,不依赖 torch/transformers。
|
||||
关键手算用例在各测试的注释里逐步展开,方便对着验算。
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from ars_opd.similarity import aggregate_similarity, edit_similarity, phi, rouge1
|
||||
|
||||
# ---------------------------------------------------------------- rouge1
|
||||
|
||||
|
||||
def test_rouge1_identical_is_exact_one():
|
||||
# 全同串必须精确 = 1.0(参考实现因分母 +1e-8 只能得 ≈0.99999998)
|
||||
s = "so x = 5 and y = 12"
|
||||
assert rouge1(s, s) == 1.0
|
||||
|
||||
|
||||
def test_rouge1_disjoint_is_zero():
|
||||
assert rouge1("a b c", "x y z") == 0.0
|
||||
|
||||
|
||||
def test_rouge1_partial_overlap_hand_computed():
|
||||
# hyp = {a, b, c}, ref = {a, b, d}:overlap = 2
|
||||
# precision = 2/3, recall = 2/3, F1 = 2·(2/3)(2/3) / (4/3) = 2/3
|
||||
assert math.isclose(rouge1("a b c", "a b d"), 2 / 3)
|
||||
|
||||
|
||||
def test_rouge1_multiset_counts_repeats():
|
||||
# 多重集语义:hyp = [x,x,x,x], ref = [x] → overlap = min(4,1) = 1
|
||||
# precision = 1/4, recall = 1/1, F1 = 2·(1/4)/(5/4) = 0.4
|
||||
# (参考实现的 set 版会给满分 1.0——数学文本重复词多,这是关键失真点)
|
||||
assert math.isclose(rouge1("x x x x", "x"), 0.4)
|
||||
|
||||
|
||||
def test_rouge1_is_bag_of_words_order_blind():
|
||||
# 词袋:只看用了哪些词,不看顺序
|
||||
assert rouge1("a b", "b a") == 1.0
|
||||
|
||||
|
||||
def test_rouge1_empty_sides():
|
||||
assert rouge1("", "a b") == 0.0
|
||||
assert rouge1("a b", "") == 0.0
|
||||
assert rouge1("", "") == 0.0
|
||||
assert rouge1(" ", "a") == 0.0 # 纯空白 split 后无词
|
||||
|
||||
|
||||
# ---------------------------------------------------------- edit_similarity
|
||||
|
||||
|
||||
def test_edit_identical_is_one():
|
||||
s = "so x = 5 and y = 12"
|
||||
assert edit_similarity(s, s) == 1.0
|
||||
|
||||
|
||||
def test_edit_totally_different_is_zero():
|
||||
# ["a","b"] vs ["c","d"]:2 次替换,dist=2, max(m,n)=2 → 1 − 1 = 0
|
||||
assert edit_similarity("a b", "c d") == 0.0
|
||||
|
||||
|
||||
def test_edit_single_substitution_hand_computed():
|
||||
# ["a","b","c"] vs ["a","x","c"]:1 次替换,dist=1, max=3 → 2/3
|
||||
assert math.isclose(edit_similarity("a b c", "a x c"), 2 / 3)
|
||||
|
||||
|
||||
def test_edit_insertion_hand_computed():
|
||||
# ["a","b"] vs ["a","x","b"]:1 次插入,dist=1, max=3 → 2/3
|
||||
assert math.isclose(edit_similarity("a b", "a x b"), 2 / 3)
|
||||
|
||||
|
||||
def test_edit_is_order_sensitive():
|
||||
# ["a","b"] vs ["b","a"]:两次替换 dist=2 → 0.0;与 rouge1 的 1.0 互补
|
||||
assert edit_similarity("a b", "b a") == 0.0
|
||||
assert rouge1("a b", "b a") == 1.0
|
||||
|
||||
|
||||
def test_edit_empty_sides():
|
||||
assert edit_similarity("", "") == 1.0 # 零距离
|
||||
assert edit_similarity("a b", "") == 0.0 # 全删
|
||||
assert edit_similarity("", "a b") == 0.0 # 全插
|
||||
|
||||
|
||||
def test_edit_asymmetric_lengths():
|
||||
# ["a"] vs ["a","b","c","d"]:3 次插入,dist=3, max=4 → 1/4
|
||||
assert math.isclose(edit_similarity("a", "a b c d"), 1 / 4)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- phi
|
||||
|
||||
|
||||
def test_phi_default_is_edit_distance():
|
||||
# 论文 §5.1 默认;"a b" vs "b a" 恰能区分两度量(edit=0, rouge1=1)
|
||||
assert phi("a b", "b a") == edit_similarity("a b", "b a") == 0.0
|
||||
|
||||
|
||||
def test_phi_dispatch():
|
||||
h, r = "a b c", "a b d"
|
||||
assert phi(h, r, metric="rouge1") == rouge1(h, r)
|
||||
assert phi(h, r, metric="edit_distance") == edit_similarity(h, r)
|
||||
|
||||
|
||||
def test_phi_unknown_metric_raises():
|
||||
with pytest.raises(ValueError, match="bleu"):
|
||||
phi("a", "a", metric="bleu")
|
||||
|
||||
|
||||
# ----------------------------------------------------- aggregate_similarity
|
||||
|
||||
|
||||
def test_aggregate_is_sum_of_phi():
|
||||
# 式(3) 手算:rollouts 与 "a b c" 的 edit 相似度分别为 1.0, 2/3, 0.0
|
||||
chunk = "a b c"
|
||||
rollouts = ["a b c", "a x c", "x y z"]
|
||||
expected = 1.0 + 2 / 3 + 0.0
|
||||
assert math.isclose(aggregate_similarity(chunk, rollouts), expected)
|
||||
|
||||
|
||||
def test_aggregate_bounds():
|
||||
# k_sem ∈ [0, N]:全同 → N,全不同 → 0
|
||||
n = 5
|
||||
assert aggregate_similarity("a b", ["a b"] * n) == float(n)
|
||||
assert aggregate_similarity("a b", ["x y"] * n) == 0.0
|
||||
|
||||
|
||||
def test_aggregate_is_continuous_soft_count():
|
||||
# φ 连续 ⇒ k_sem 非整数是常态(区别于 token 精确匹配的硬计数)
|
||||
k = aggregate_similarity("a b c", ["a b c", "a x c"])
|
||||
assert 1.0 < k < 2.0
|
||||
|
||||
|
||||
def test_aggregate_empty_rollouts():
|
||||
assert aggregate_similarity("a b", []) == 0.0
|
||||
|
||||
|
||||
def test_aggregate_metric_passthrough():
|
||||
# "a b" vs "b a":edit 全零,rouge1 全满——验证 metric 真的传下去了
|
||||
chunk, rollouts = "a b", ["b a", "b a"]
|
||||
assert aggregate_similarity(chunk, rollouts, metric="edit_distance") == 0.0
|
||||
assert aggregate_similarity(chunk, rollouts, metric="rouge1") == 2.0
|
||||
Reference in New Issue
Block a user