first commit

This commit is contained in:
2026-09-22 10:48:18 +08:00
commit 2006f7b4a4
20 changed files with 1877 additions and 0 deletions

282
app/indicators.py Normal file
View File

@@ -0,0 +1,282 @@
from __future__ import annotations
from collections import defaultdict
from .excel_parse import class_sort_key
from .kalman import (
fit_qr,
hamilton,
holdout_hits,
kalman_filter,
monte_carlo,
ranks_desc,
zscores,
)
TRACK_DEFAULTS = {
"物理": [{"id": "t88", "name": "特控线上", "k": 88}],
"历史": [
{"id": "top5", "name": "尖子生", "k": 5},
{"id": "top12", "name": "600分以上", "k": 12},
{"id": "top52", "name": "特控线上", "k": 52},
],
}
def infer_track_group(track: str) -> str:
t = track or ""
if "历史" in t:
return "历史"
if "物理" in t:
return "物理"
return t or "未分方向"
def default_targets(groups: list[str]) -> dict[str, list[dict]]:
out = {}
for g in groups:
if g in TRACK_DEFAULTS:
out[g] = [dict(x) for x in TRACK_DEFAULTS[g]]
else:
out[g] = [{"id": "top20", "name": "年级前20%", "k": 20}]
return out
def select_students(parsed: dict, settings: dict) -> list[dict]:
include_art = settings.get("include_art", False)
include_leave = settings.get("include_leave", False)
include_mingyuan = settings.get("include_mingyuan", True)
include_other = settings.get("include_other", True)
exams = settings.get("exams") or parsed["exams"]
selected = []
for s in parsed["students"]:
if s["art"] and not include_art:
continue
if s["leave"] and not include_leave:
continue
st = s["status"] or "在籍"
if st == "明远" and not include_mingyuan:
continue
if st not in ("在籍", "明远", "") and not include_other:
continue
scores = {e: s["scores"].get(e) for e in exams}
if all(v is None for v in scores.values()):
continue
row = dict(s)
row["scores"] = scores
row["group"] = infer_track_group(s["track"])
selected.append(row)
return selected
def _forecast_horizon(q: float, p: float, horizon: int) -> float:
return p + max(1, horizon) * q
def analyze(parsed: dict, settings: dict) -> dict:
exams = settings.get("exams") or parsed["exams"]
if not exams:
raise ValueError("没有可用的考试列")
horizon = int(settings.get("horizon", 1))
n_sim = int(settings.get("n_sim", 600))
selected = select_students(parsed, settings)
groups = sorted({s["group"] for s in selected})
targets = settings.get("targets") or default_targets(groups)
# fit Q,R per group on z-scores
group_models = {}
for g in groups:
members = [s for s in selected if s["group"] == g]
z_by_exam = {}
for e in exams:
z_by_exam[e] = zscores([s["scores"].get(e) for s in members])
for i, s in enumerate(members):
s["z"] = [z_by_exam[e][i] for e in exams]
s["ranks"] = []
for e in exams:
rk = ranks_desc([s["scores"].get(e) for s in members])
for i, s in enumerate(members):
s["ranks"].append(rk[i])
series = [s["z"] for s in members]
q, r, rmse = fit_qr(series)
group_models[g] = {"q": q, "r": r, "rmse": rmse, "n": len(members)}
for s in members:
fr = kalman_filter(s["z"], q, r)
s["kf_x"] = fr.x
s["kf_p"] = fr.p
s["kf_xs"] = fr.xs
s["kf_ps"] = fr.ps
if fr.x is None:
s["pred_mean"] = None
s["pred_var"] = None
else:
s["pred_mean"] = fr.x
s["pred_var"] = _forecast_horizon(q, fr.p if fr.p is not None else r, horizon)
results = []
for g in groups:
members = [s for s in selected if s["group"] == g]
g_targets = targets.get(g) or default_targets([g])[g]
model = group_models[g]
eligible = [s for s in members if s["pred_mean"] is not None]
last_ranks = [s["ranks"][-1] if s["ranks"] else None for s in members]
holdouts = []
if len(exams) >= 3:
series = [s["z"] for s in members]
true_ranks = [s["ranks"][-1] if s["ranks"] else None for s in members]
last_train_ranks = [s["ranks"][-2] if len(s["ranks"]) >= 2 else None for s in members]
for t in g_targets:
k = min(int(t["k"]), max(1, sum(1 for r in true_ranks if r is not None)))
h = holdout_hits(
series,
last_train_ranks,
true_ranks,
model["q"],
model["r"],
k,
)
h["target_id"] = t["id"]
h["target_name"] = t["name"]
h["k"] = k
holdouts.append(h)
target_blocks = []
for t in g_targets:
k = int(t["k"])
k = max(1, min(k, len(eligible)))
means = [s["pred_mean"] for s in eligible]
variances = [s["pred_var"] for s in eligible]
class_ids = [s["class_name"] for s in eligible]
probs, class_q = monte_carlo(means, variances, class_ids, k, n_sim=n_sim)
for s, p in zip(eligible, probs):
s.setdefault("probs", {})[t["id"]] = p
# current on-line: last exam rank <= k
class_now = defaultdict(int)
class_n = defaultdict(int)
class_exp = defaultdict(float)
class_mingyuan_exp = defaultdict(float)
for s in members:
class_n[s["class_name"]] += 1
for s in eligible:
class_exp[s["class_name"]] += s["probs"][t["id"]]
if s.get("status") == "明远":
class_mingyuan_exp[s["class_name"]] += s["probs"][t["id"]]
if s["ranks"] and s["ranks"][-1] is not None and s["ranks"][-1] <= k:
class_now[s["class_name"]] += 1
class_names = sorted(class_n.keys(), key=class_sort_key)
expected = [class_exp[c] for c in class_names]
assigned = hamilton(expected, k)
rows = []
for c, a in zip(class_names, assigned):
qtl = class_q.get(c, {"p20": 0, "p50": 0, "p80": 0, "mean": 0})
rows.append(
{
"class_name": c,
"n": class_n[c],
"current": class_now[c],
"expected": round(class_exp[c], 2),
"assigned": a,
"floor": int(qtl["p20"]),
"median": int(qtl["p50"]),
"stretch": int(qtl["p80"]),
"delta": a - class_now[c],
"mingyuan_expected": round(class_mingyuan_exp[c], 2),
}
)
critical = []
for s in eligible:
p = s["probs"][t["id"]]
if 0.2 <= p <= 0.8:
critical.append(
{
"sid": s["sid"],
"name": s["name"],
"class_name": s["class_name"],
"p": round(p, 3),
"last_rank": s["ranks"][-1] if s["ranks"] else None,
"status": s.get("status") or "",
}
)
critical.sort(key=lambda x: -x["p"])
target_blocks.append(
{
"id": t["id"],
"name": t["name"],
"k": k,
"classes": rows,
"critical_n": len(critical),
"critical": critical[:80],
"sum_expected": round(sum(expected), 2),
}
)
results.append(
{
"group": g,
"n": len(members),
"model": {
"q": model["q"],
"r": model["r"],
"rmse": round(model["rmse"], 3) if model["rmse"] is not None else None,
"horizon": horizon,
"n_sim": n_sim,
},
"holdout": holdouts,
"targets": target_blocks,
}
)
student_views = []
for s in selected:
student_views.append(
{
"sid": s["sid"],
"name": s["name"],
"class_name": s["class_name"],
"group": s["group"],
"track": s["track"],
"subjects": s["subjects"],
"status": s.get("status") or "",
"move": s.get("move") or "",
"art": s["art"],
"leave": s["leave"],
"scores": s["scores"],
"z": s.get("z"),
"ranks": s.get("ranks"),
"kf_xs": s.get("kf_xs"),
"kf_ps": s.get("kf_ps"),
"pred_mean": s.get("pred_mean"),
"pred_var": s.get("pred_var"),
"probs": {k: round(v, 4) for k, v in (s.get("probs") or {}).items()},
}
)
return {
"exams": exams,
"horizon": horizon,
"n_selected": len(selected),
"n_total": len(parsed["students"]),
"groups": results,
"students": student_views,
}
def class_comment(row: dict, target_name: str) -> str:
assigned = row["assigned"]
current = row["current"]
expected = row["expected"]
floor = row["floor"]
stretch = row["stretch"]
name = row["class_name"]
bits = [
f"{name}班{target_name}下达 {assigned} 人(滤波期望 {expected:.1f})。"
]
if assigned > current:
bits.append(f"比最近一次在线人数多 {assigned - current} 人,班级里有上升轨迹的临界生,需要把潜力兑现。")
elif assigned < current:
bits.append(f"比最近一次在线人数少 {current - assigned} 人,说明部分当前在线学生轨迹不稳,重点是保住临界生。")
else:
bits.append("与最近一次在线人数持平,按现有轨迹属于维持性目标。")
bits.append(f"模拟分布大约在 {floor}–{stretch} 人之间,超越下达人数才算跑赢预估。")
return "".join(bits)