feat(classify): super-linear positive weighting by hit count
正向改为按命中次数 n(require_title + reaction + charge + weak)做平方加权,使多信号同现的真 reaction 不被单个负向词打穿(1+1>2、1+1+1>5);标题不含 require_title 直接 0 分。库内 796 条有效视频复测:线以下误伤由 47 降至 14。
This commit is contained in:
1 parent
742c67a1c3
commit
337e379514
4 files changed
+78
-75
No files matched your search
+14
-11
@@ -257,19 +257,21 @@ Telegram 私聊发链接/BV 号 ───┘
|
|||||||
|
|
||||||
```
|
```
|
||||||
# 硬门槛
|
# 硬门槛
|
||||||
title 含 "咻咻满"(全半角/大小写归一化后),否则直接丢弃
|
title 含 "咻咻满"(全半角/大小写归一化后),否则直接 0 分(丢弃)
|
||||||
up_mid 命中黑名单 → 直接排除(永不通知,连候选池都不进)
|
up_mid 命中黑名单 → 直接排除(永不通知,连候选池都不进)
|
||||||
|
|
||||||
score = 0
|
# 正向命中次数 n(每个命中记 1)
|
||||||
+3 标题同时含 "咻咻满" 与 reaction|反应(强正向;只含 reaction 不成立)
|
n = 1 # 标题含 "咻咻满"
|
||||||
+3 标题含 加急|特急|充电|专场|转场 中任意 >=2 个
|
+ count(reaction|反应)
|
||||||
+1 标题含其中恰好 1 个
|
+ count(加急|特急|充电|专场|转场)
|
||||||
+1 标题含 双人|路人|第一次|首次(弱正向)
|
+ count(双人|路人|第一次|首次)
|
||||||
+2 时长 > 60 分钟(仅当已命中 reaction 或 charge 时计入)
|
|
||||||
+1 时长 > 30 分钟(同上,与上一条不叠加,取大)
|
score = n * n # 超线性:1→1、2→4、3→9、4→16(1+1>2、1+1+1>5)
|
||||||
-6 标题含 歌切|切片|纯享|搬运|完整版|录播|录像
|
+ 2 # 时长 > 60 分钟(与下一条不叠加,取大)
|
||||||
-6 标题带日期(2026-10-28 / 2026/10/25 / 2026.10.26 / 2024年11月24日 / 10月25日)
|
+ 1 # 时长 > 30 分钟
|
||||||
-3 UP 为 咻咻满本人(mid=37754047)
|
- 6 # 标题含 歌切|切片|纯享|搬运|完整版|录播|录像
|
||||||
|
- 6 # 标题带日期(2026-10-28 / 2026/10/25 / 2026.10.26 / 2024年11月24日 / 10月25日)
|
||||||
|
- 3 # UP 为 咻咻满本人(mid=37754047)
|
||||||
|
|
||||||
通知阈值 = 4
|
通知阈值 = 4
|
||||||
0 <= score < 4 且含"咻咻满" → 进看板"候选池"(7 天后删除),不通知
|
0 <= score < 4 且含"咻咻满" → 进看板"候选池"(7 天后删除),不通知
|
||||||
@@ -279,6 +281,7 @@ score < 0 → 丢弃
|
|||||||
**几条设计依据**:
|
**几条设计依据**:
|
||||||
|
|
||||||
- **强正向必须"咻咻满 + reaction"同现**:B 站搜索会命中标题根本不含关键词的结果(简介/标签/相似内容),所以"标题含 reaction"本身不足以判定为高置信,必须与 `require_title` 同时成立;
|
- **强正向必须"咻咻满 + reaction"同现**:B 站搜索会命中标题根本不含关键词的结果(简介/标签/相似内容),所以"标题含 reaction"本身不足以判定为高置信,必须与 `require_title` 同时成立;
|
||||||
|
- **正向按命中次数超线性加权(n²)**:多个正向词同现(如"咻咻满 + reaction + 充电")时不能简单线性和,否则一个负向词(-6)就能把多信号真 reaction 打到线下;`1+1>2`、`1+1+1>5` 用平方实现;
|
||||||
- **"听/看"等宽泛词已移除**:几乎所有 reaction 标题都含,无区分度;
|
- **"听/看"等宽泛词已移除**:几乎所有 reaction 标题都含,无区分度;
|
||||||
- **时长只在已命中 reaction/charge 时加权**:避免"完整录播/直播录像"这类既无 reaction 也无 charge 的长视频仅靠时长进池;
|
- **时长只在已命中 reaction/charge 时加权**:避免"完整录播/直播录像"这类既无 reaction 也无 charge 的长视频仅靠时长进池;
|
||||||
- **带日期的标题按强负向处理**:`2026-10-28`、`10月25日` 这类通常对应某场直播的录播/回放/录像/自用收藏;实测对真 reaction 标题零误伤,代价是极少数"真 reaction 恰好写日期"会被漏掉;
|
- **带日期的标题按强负向处理**:`2026-10-28`、`10月25日` 这类通常对应某场直播的录播/回放/录像/自用收藏;实测对真 reaction 标题零误伤,代价是极少数"真 reaction 恰好写日期"会被漏掉;
|
||||||
|
|||||||
+15
-23
@@ -74,11 +74,7 @@ _DATE_PATTERNS = (
|
|||||||
)
|
)
|
||||||
_DATE_RE = re.compile("|".join(_DATE_PATTERNS))
|
_DATE_RE = re.compile("|".join(_DATE_PATTERNS))
|
||||||
|
|
||||||
# 各信号的权重(见 project.md §3.3)。
|
# 负向权重(见 project.md §3.3)。正向改为「命中次数」超线性加权,见 score_item。
|
||||||
REACTION_SCORE = 3
|
|
||||||
CHARGE_MULTI_SCORE = 3
|
|
||||||
CHARGE_SINGLE_SCORE = 1
|
|
||||||
WEAK_SCORE = 1
|
|
||||||
NEGATIVE_SCORE = 6
|
NEGATIVE_SCORE = 6
|
||||||
DATE_SCORE = 6
|
DATE_SCORE = 6
|
||||||
SELF_SCORE = 3
|
SELF_SCORE = 3
|
||||||
@@ -98,29 +94,25 @@ def score_item(
|
|||||||
) -> int:
|
) -> int:
|
||||||
"""按 project.md §3.3 计算候选分数。
|
"""按 project.md §3.3 计算候选分数。
|
||||||
|
|
||||||
强正向 ``reaction`` 只在标题同时命中 ``require_title`` 时成立;时长只作为
|
正向按**命中次数**超线性加权:每命中一个正向词(``require_title`` / ``reaction``
|
||||||
已存在 reaction / charge 信号时的加权,避免纯长录像仅靠时长进池。
|
/ ``charge`` / ``weak_positive``)记 1 次,正向得分 = 命中次数²(1→1、2→4、
|
||||||
|
3→9……),使多信号同现的真 reaction 不会被单个负向词打穿(1+1>2、1+1+1>5)。
|
||||||
|
标题不含 ``require_title`` 直接 0 分;时长只在此基础上已有命中时加权。
|
||||||
"""
|
"""
|
||||||
score = 0
|
|
||||||
require_title = str(classify.get("require_title") or "")
|
require_title = str(classify.get("require_title") or "")
|
||||||
|
if require_title and not passes_required_title(title, require_title):
|
||||||
|
return 0
|
||||||
|
|
||||||
reaction_matched = bool(matched_words(title, classify.get("reaction")))
|
hits = 0
|
||||||
reaction_hit = reaction_matched and (
|
if require_title:
|
||||||
not require_title or passes_required_title(title, require_title)
|
hits += 1
|
||||||
)
|
hits += len(matched_words(title, classify.get("reaction")))
|
||||||
if reaction_hit:
|
hits += len(matched_words(title, classify.get("charge")))
|
||||||
score += REACTION_SCORE
|
hits += len(matched_words(title, classify.get("weak_positive")))
|
||||||
|
|
||||||
charge_hits = matched_words(title, classify.get("charge"))
|
score = hits * hits
|
||||||
if len(charge_hits) >= 2:
|
|
||||||
score += CHARGE_MULTI_SCORE
|
|
||||||
elif len(charge_hits) == 1:
|
|
||||||
score += CHARGE_SINGLE_SCORE
|
|
||||||
|
|
||||||
if matched_words(title, classify.get("weak_positive")):
|
if hits:
|
||||||
score += WEAK_SCORE
|
|
||||||
|
|
||||||
if reaction_hit or charge_hits:
|
|
||||||
boost = classify.get("duration_boost") or {}
|
boost = classify.get("duration_boost") or {}
|
||||||
gt_60 = int(boost.get("gt_60min", 2))
|
gt_60 = int(boost.get("gt_60min", 2))
|
||||||
gt_30 = int(boost.get("gt_30min", 1))
|
gt_30 = int(boost.get("gt_30min", 1))
|
||||||
|
|||||||
+47
-39
@@ -68,59 +68,67 @@ def test_date_hits():
|
|||||||
assert date_hits("咻咻满 reaction") == []
|
assert date_hits("咻咻满 reaction") == []
|
||||||
|
|
||||||
|
|
||||||
def test_score_strong_reaction_requires_title():
|
def test_hit_count_is_superlinear():
|
||||||
# 强正向 reaction 只在标题含 require_title 时成立。
|
# 正向命中次数 n:require_title + reaction + charge + weak,得分 = n^2
|
||||||
assert score("咻咻满 reaction", duration=60) == 3
|
# 1 -> 1, 2 -> 4, 3 -> 9(满足 1+1>2、1+1+1>5)
|
||||||
assert score("咻咻满 反应", duration=60) == 3
|
assert score("咻咻满", duration=60) == 1
|
||||||
|
assert score("咻咻满 reaction", duration=60) == 4
|
||||||
|
assert score("咻咻满 反应", duration=60) == 4
|
||||||
|
assert score("咻咻满 reaction 充电", duration=60) == 9
|
||||||
|
assert score("咻咻满 reaction 加急充电专场", duration=60) == 25
|
||||||
|
assert score("咻咻满 充电", duration=60) == 4
|
||||||
|
assert score("咻咻满 加急专场", duration=60) == 9
|
||||||
|
assert score("咻咻满 双人", duration=60) == 4
|
||||||
|
assert score("咻咻满 reaction 双人", duration=60) == 9
|
||||||
|
|
||||||
|
|
||||||
|
def test_reaction_requires_title():
|
||||||
|
# 标题不含 require_title 直接 0 分,即使命中 reaction
|
||||||
assert score("其他 UP 的 reaction", duration=61 * 60) == 0
|
assert score("其他 UP 的 reaction", duration=61 * 60) == 0
|
||||||
|
|
||||||
|
|
||||||
def test_score_charge_levels():
|
def test_duration_boost():
|
||||||
assert score("咻咻满 充电", duration=60) == 1
|
assert score("咻咻满 reaction", duration=61 * 60) == 6
|
||||||
assert score("咻咻满 加急专场", duration=60) == 3
|
assert score("咻咻满 reaction", duration=31 * 60) == 5
|
||||||
assert score("咻咻满 reaction 充电", duration=60) == 4
|
assert score("咻咻满 reaction", duration=30 * 60) == 4
|
||||||
assert score("咻咻满 reaction 加急充电专场", duration=60) == 6
|
# 只有 require_title 一个命中时,时长仍计入(基础分偏低,不足以过线)
|
||||||
|
assert score("咻咻满", duration=0) == 1
|
||||||
|
assert score("咻咻满", duration=61 * 60) == 3
|
||||||
|
assert score("咻咻满 双人", duration=61 * 60) == 6
|
||||||
|
|
||||||
|
|
||||||
def test_score_weak_positive():
|
def test_negative_and_self():
|
||||||
assert score("咻咻满 双人", duration=60) == 1
|
assert score("咻咻满 切片", duration=60) == -5
|
||||||
assert score("咻咻满 reaction 双人", duration=60) == 4
|
assert score("咻咻满 reaction 切片", duration=60) == -2
|
||||||
|
assert score("咻咻满 reaction 直播录像", duration=60) == -2
|
||||||
|
assert score("咻咻满 reaction", up_mid=37754047, duration=60) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_duration_only_counts_with_signal():
|
def test_date_negative():
|
||||||
assert score("咻咻满 reaction", duration=61 * 60) == 5
|
assert score("咻咻满 reaction 2026-10-28 完整录播", duration=60) == -8
|
||||||
assert score("咻咻满 reaction", duration=31 * 60) == 4
|
assert score("咻咻满 reaction 2026-10-28", duration=60) == -2
|
||||||
assert score("咻咻满 reaction", duration=30 * 60) == 3
|
assert score("咻咻满 充电 10月25日", duration=60) == -2
|
||||||
# 无 reaction / charge 信号时,时长不给分(避免纯长录像进池)。
|
|
||||||
assert score("咻咻满", duration=61 * 60) == 0
|
|
||||||
assert score("咻咻满 双人", duration=61 * 60) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_score_negative_and_self():
|
|
||||||
assert score("咻咻满 切片", duration=60) == -6
|
|
||||||
assert score("咻咻满 reaction 切片", duration=60) == -3
|
|
||||||
assert score("咻咻满 reaction 直播录像", duration=60) == -3
|
|
||||||
assert score("咻咻满 reaction", up_mid=37754047, duration=60) == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_score_date_negative():
|
|
||||||
assert score("咻咻满 reaction 2026-10-28 完整录播", duration=60) == -9
|
|
||||||
assert score("咻咻满 reaction 2026-10-28", duration=60) == -3
|
|
||||||
assert score("咻咻满 充电 10月25日", duration=60) == -5
|
|
||||||
disabled = dict(CLASSIFY, date_negative=False)
|
disabled = dict(CLASSIFY, date_negative=False)
|
||||||
assert score("咻咻满 reaction 2026-10-28", duration=60, classify=disabled) == 3
|
assert score("咻咻满 reaction 2026-10-28", duration=60, classify=disabled) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_hit_survives_single_negative():
|
||||||
|
# 回归:命中 咻咻满+reaction+充电 的 204min reaction 标题,扣掉「切片」后仍应过线
|
||||||
|
title = "【充电2.0 | 咻咻满reaction】翻唱和直播切片完全是在开盲盒呀!不知道今天会开到哪一款"
|
||||||
|
assert score(title, duration=204 * 60) == 5
|
||||||
|
assert score(title, duration=60) == 3
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
test_title_helpers()
|
test_title_helpers()
|
||||||
test_parse_duration()
|
test_parse_duration()
|
||||||
test_date_hits()
|
test_date_hits()
|
||||||
test_score_strong_reaction_requires_title()
|
test_hit_count_is_superlinear()
|
||||||
test_score_charge_levels()
|
test_reaction_requires_title()
|
||||||
test_score_weak_positive()
|
test_duration_boost()
|
||||||
test_duration_only_counts_with_signal()
|
test_negative_and_self()
|
||||||
test_score_negative_and_self()
|
test_date_negative()
|
||||||
test_score_date_negative()
|
test_multi_hit_survives_single_negative()
|
||||||
print("OK: 候选标题归一化、时长解析与分类打分测试全部通过")
|
print("OK: 候选标题归一化、时长解析与分类打分测试全部通过")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ def write_config(tmp, **discover):
|
|||||||
"date_negative": True,
|
"date_negative": True,
|
||||||
"duration_boost": {"gt_30min": 1, "gt_60min": 2},
|
"duration_boost": {"gt_30min": 1, "gt_60min": 2},
|
||||||
},
|
},
|
||||||
"notify": {"min_score": 3, "max_items_per_message": 10},
|
"notify": {"min_score": 4, "max_items_per_message": 10},
|
||||||
}
|
}
|
||||||
path = Path(tmp) / "config.json"
|
path = Path(tmp) / "config.json"
|
||||||
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||||
@@ -130,7 +130,7 @@ def test_filters_and_scoring():
|
|||||||
assert summary.blacklisted == 1
|
assert summary.blacklisted == 1
|
||||||
assert summary.candidates == 1 # aid 6
|
assert summary.candidates == 1 # aid 6
|
||||||
assert summary.notified == 0
|
assert summary.notified == 0
|
||||||
assert repo.get_candidate(6)["score"] == 0
|
assert repo.get_candidate(6)["score"] == 1
|
||||||
assert repo.get_candidate(2) is None
|
assert repo.get_candidate(2) is None
|
||||||
assert repo.get_candidate(4) is None
|
assert repo.get_candidate(4) is None
|
||||||
assert repo.get_candidate(5) is None
|
assert repo.get_candidate(5) is None
|
||||||
|
|||||||
Reference in new issue
Block a user