Files
gaokao-volunteer-system/data/crowd_db/crowd_detector.py

228 lines
7.6 KiB
Python
Raw Normal View History

"""扎堆检测算法 (T2.3)
核心入口detect_crowd_risk(plan, user_score, province) -> list[RiskFinding]
算法步骤
1. 加载省份对应的 crowd_db使用 CrowdDBLoader
2. 遍历用户方案plan每条志愿
3. crowd_db 的对应分数段中查找匹配
4. 风险等级根据 frequency 划分
- frequency >= 4: high
- frequency 2-3 : medium
- frequency 1 : low
- frequency 0 : 跳过不构成扎堆
5. 返回 RiskFinding 列表 frequency 降序+ 替代方案
"""
from __future__ import annotations
from dataclasses import dataclass, field, asdict
from typing import Any, Dict, Iterable, List, Optional, Union
from data.crowd_db.loader import CrowdDBLoader, CrowdRecommendation
# 兼容多种 plan 条目形态dict / dataclass / tuple
PlanEntry = Union[Dict[str, Any], CrowdRecommendation, tuple, list]
def plan_entry(school: str, major: Optional[str] = None) -> Dict[str, Any]:
"""构造一条 plan 条目dict 形态)。供调用方与测试使用。"""
return {"school": school, "major": major}
@dataclass
class RiskFinding:
"""扎堆检测结果中的一条风险记录"""
school: str
major: Optional[str]
frequency: int
risk_level: str
platforms: List[str] = field(default_factory=list)
predicted_increase: int = 0
alternatives: List[Dict[str, Any]] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
def _normalize_entry(entry: PlanEntry) -> Dict[str, Any]:
"""把各种形态的 plan 条目统一为 dict。
支持
- dict必须有 school可选 major
- CrowdRecommendationname -> school, major 保留
- tuple / list[school, major] [school]
- 其他形态str() 兜底
"""
if isinstance(entry, dict):
school_val = entry.get("school") or entry.get("name") or ""
# 防御: school 是非字符串(如 0/None时降级为 str
if not isinstance(school_val, str):
school_val = str(school_val) if school_val else ""
return {"school": school_val, "major": entry.get("major")}
if isinstance(entry, CrowdRecommendation):
return {"school": entry.name, "major": entry.major}
if isinstance(entry, (tuple, list)):
if len(entry) >= 2:
school_val = entry[0]
return {
"school": school_val
if isinstance(school_val, str)
else str(school_val),
"major": entry[1],
}
if len(entry) == 1:
school_val = entry[0]
return {
"school": school_val
if isinstance(school_val, str)
else str(school_val),
"major": None,
}
# 兜底: 任意对象 str() 化
return {"school": str(entry), "major": None}
def _risk_level_from_frequency(frequency: int) -> str:
"""根据推荐频次计算风险等级。"""
if frequency >= 4:
return "high"
if frequency >= 2:
return "medium"
if frequency >= 1:
return "low"
return "none" # 0 不构成风险
def _school_matches(school_a: str, school_b: str) -> bool:
"""院校名匹配。
规则
- 完全相等命中
- 简称/全称包含仅当较短一方长度 >= 4 时命中
这样既保留长沙民政 -> 长沙民政职业技术学院的常用简称匹配
也避免大学学院湖南这类过短泛词产生系统性误报
"""
school_a = school_a.strip()
school_b = school_b.strip()
if not school_a or not school_b:
return False
if school_a == school_b:
return True
shorter_len = min(len(school_a), len(school_b))
return shorter_len >= 4 and (school_a in school_b or school_b in school_a)
def _major_matches(plan_major: Optional[str], rec_major: str) -> bool:
"""专业匹配规则:
- 计划未指定专业None / 按院校命中即可
- 计划指定专业完全相等视为匹配
"""
if not plan_major:
return True
if not rec_major:
return False
return plan_major.strip() == rec_major.strip()
def detect_crowd_risk(
plan: Iterable[PlanEntry],
user_score: int,
province: str,
loader: Optional[CrowdDBLoader] = None,
) -> List[RiskFinding]:
"""检测方案的扎堆风险。
Args:
plan: 用户志愿方案dict / dataclass / tuple 的可迭代对象
user_score: 用户分数
province: 招生省份
loader: 可选注入的 CrowdDBLoader便于测试
Returns:
RiskFinding 列表 frequency 降序排序
- 方案为空 / 省份无数据 / 全部不命中返回空列表
- frequency=0 的记录会被跳过不构成扎堆
"""
if not plan:
return []
if loader is None:
loader = CrowdDBLoader()
# 1) 取该分数段内的所有 crowd_db 记录
recs = loader.find_recommendations(province, user_score)
if not recs:
return []
findings: List[RiskFinding] = []
# 2) 遍历 plan
for raw_entry in plan:
entry = _normalize_entry(raw_entry)
school = entry["school"]
major = entry["major"]
if not school:
continue
# 3) 在该分数段 recs 中查找匹配
for rec in recs:
# 防御: loader 可能混入非 dict 元素(如 None / 字符串 / 数字)
if not isinstance(rec, dict):
continue
rec_name = rec.get("name")
# 防御: name 缺失或非字符串时跳过(避免 .strip() 抛错)
if not isinstance(rec_name, str) or not rec_name.strip():
continue
if not _school_matches(school, rec_name):
continue
if not _major_matches(major, rec.get("major", "")):
continue
freq_raw = rec.get("frequency", 0)
# 防御: frequency 字段异常(非数值)时按 0 处理
try:
freq = int(freq_raw)
except (TypeError, ValueError):
freq = 0
if freq <= 0:
continue
findings.append(
RiskFinding(
school=rec_name,
major=rec.get("major") or major,
frequency=freq,
risk_level=_risk_level_from_frequency(freq),
platforms=list(rec.get("platforms") or []),
predicted_increase=int(rec.get("predicted_increase") or 0),
alternatives=list(rec.get("alternatives") or []),
)
)
break # 一条 plan entry 命中一次即可
# 4) 按风险等级排序frequency 降序 → 等级高→低)
findings.sort(key=lambda f: f.frequency, reverse=True)
return findings
# ---------- 命令行测试入口 ----------
if __name__ == "__main__":
# 演示用575分湖南示例
sample_plan = [
plan_entry("长沙理工大学", "会计学"),
plan_entry("湖南文理学院", "汉语言文学"),
plan_entry("某某野鸡大学", "考古学"),
]
findings = detect_crowd_risk(sample_plan, user_score=575, province="湖南")
print(f"📊 575分湖南 方案扎堆检测:{len(findings)} 条风险")
for f in findings:
print(
f" - {f.school} {f.major or ''} "
f"(频次:{f.frequency}, 风险:{f.risk_level}, "
f"+{f.predicted_increase}分, 平台:{','.join(f.platforms)})"
)
for a in f.alternatives:
print(f" └ 替代: {a.get('name')} {a.get('major', '')}")