使用梅校的算法
This commit is contained in:
44
README.md
44
README.md
@@ -1,6 +1,6 @@
|
||||
# 航标 · 班级达线指标预估
|
||||
# 指标测算 · 相邻层次法
|
||||
|
||||
B/S 系统:管理员上传历次大考总分,系统用卡尔曼滤波估计每个学生的稳定水平,模拟后续考试的年级排名,把达线概率加总后下达各班指标。下达人数是“按现有轨迹本来会到的人数”,班级工作是超越它。
|
||||
B/S 系统:上传历次大考总分,按方向内名次把学生分到固定宽度层次;每次只比较相邻两次考试的“上一层次 → 本次到线”命中人数;汇总后做拟合概率,再用最后一次考试各班层次人数摊派尖子线和特控线指标。
|
||||
|
||||
## 启动
|
||||
|
||||
@@ -11,29 +11,37 @@ pip install -r requirements.txt
|
||||
python3 -m app
|
||||
```
|
||||
|
||||
浏览器打开 http://服务器IP:8765 (本机也可用 http://127.0.0.1:8765)
|
||||
服务默认监听 `0.0.0.0:8765`。可用环境变量覆盖:`HOST=127.0.0.1 PORT=8765 ./run.sh`
|
||||
浏览器打开 http://127.0.0.1:8765 。服务默认监听 `0.0.0.0:8765`,可用 `HOST`、`PORT` 覆盖。
|
||||
|
||||
默认账号:`admin` / `hangbiao2026`(请在 `app/config.py` 修改)
|
||||
默认账号:`admin` / `hangbiao2026`(可在 `app/config.py` 修改)。
|
||||
|
||||
## 成绩表要求
|
||||
|
||||
- Excel `.xlsx`
|
||||
- 至少包含:学号或姓名、班级、多次考试总分
|
||||
- 建议包含:方向(物理/历史)、选科、学籍、异动
|
||||
- 物理/历史分表、艺体表、异动表都可以;系统会识别并在第二步让你勾选口径
|
||||
- 至少包含:学号/姓名、班级、多次考试总分
|
||||
- 建议包含:方向、选科、学籍、异动
|
||||
- 物理/历史分表可直接识别;同分按同名次,后续名次递增
|
||||
- 未排除的缺考学生按 0 分参加同方向排名
|
||||
|
||||
同目录的 `2024级指标分析成绩基础数据_5次考试.xlsx` 可在首页一键演示。
|
||||
同目录 `basedata.xlsx` 上传后可作为按新算法复算的示例。
|
||||
|
||||
## 四步分析
|
||||
## Web 设置
|
||||
|
||||
1. 数据体检:读到哪些人、哪些考试、分数尺度
|
||||
2. 口径与目标:算谁、年级总盘子、预估步长
|
||||
3. 滤波与回测:Q/R 含义,以及用历史考试验证准不准
|
||||
4. 下达指标:班级期望 / 下达 / 保底 / 冲刺,点班级看学生,点学生看轨迹
|
||||
- 勾选是否排除异动学生(异动/备注非空,或来源为异动/艺体表)
|
||||
- 修改每个方向的尖子线人数、特控线人数、每层人数
|
||||
- 选择参与计算哪些考试;只比较勾选后的相邻两次
|
||||
- 第三步查看每次相邻命中概率、汇总概率和拟合概率;最后一次考试既参与相邻转移计算,也用于最终班级摊派
|
||||
- 第四步查看班级摊派,并可导出总表
|
||||
|
||||
## 指标怎么理解
|
||||
## 总表结构
|
||||
|
||||
- **下达指标**:滤波期望取整,合计等于年级目标人数
|
||||
- **保底 / 冲刺**:模拟分布大约 20% / 80% 分位
|
||||
- **相对最近一次**:预估相对最近一场已在线人数的增减,用来找上升生和不稳的在线生
|
||||
导出的 Excel 包含:
|
||||
|
||||
1. 物理方向人员
|
||||
2. 历史方向人员
|
||||
3. 物理类概率分析
|
||||
4. 历史类概率分析
|
||||
5. 物理指标数
|
||||
6. 历史指标数
|
||||
|
||||
结构与 `basedata.xlsx` 对应;概率和指标为本系统按当前 Web 口径重新计算后的结果。
|
||||
|
||||
@@ -4,7 +4,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
DATA_DIR = BASE_DIR / "data"
|
||||
UPLOAD_DIR = DATA_DIR / "uploads"
|
||||
DB_PATH = DATA_DIR / "hangbiao.db"
|
||||
SAMPLE_XLSX = BASE_DIR / "2024级指标分析成绩基础数据_5次考试.xlsx"
|
||||
SAMPLE_XLSX = BASE_DIR / "basedata.xlsx"
|
||||
|
||||
SECRET_KEY = "hangbiao-dev-secret-change-me"
|
||||
ADMIN_USER = "admin"
|
||||
|
||||
@@ -22,6 +22,14 @@ EXAM_HINT = re.compile(
|
||||
re.I,
|
||||
)
|
||||
SKIP_SHEETS = ("说明", "readme", "目录")
|
||||
|
||||
|
||||
def _is_skip_sheet(name: str) -> bool:
|
||||
n = name.lower()
|
||||
if any(x in n for x in SKIP_SHEETS):
|
||||
return True
|
||||
return "概率分析" in n or "指标数" in n
|
||||
|
||||
ART_RE = re.compile(r"艺术|体育|艺体")
|
||||
LEAVE_RE = re.compile(r"请假|出国|休学|国际|不在校|港澳|港籍.*假")
|
||||
|
||||
@@ -76,6 +84,8 @@ def parse_workbook(path: Path | BytesIO) -> dict:
|
||||
|
||||
for ws in wb.worksheets:
|
||||
kind = _sheet_kind(ws.title)
|
||||
if _is_skip_sheet(ws.title):
|
||||
continue
|
||||
rows = ws.iter_rows(values_only=True)
|
||||
try:
|
||||
header_row = next(rows)
|
||||
@@ -201,7 +211,7 @@ def summarize_parse(parsed: dict) -> dict:
|
||||
tracks = {}
|
||||
classes = {}
|
||||
status = {}
|
||||
art = leave = 0
|
||||
art = leave = moves = 0
|
||||
exam_present = {e: 0 for e in exams}
|
||||
for s in students:
|
||||
tracks[s["track"] or "未分方向"] = tracks.get(s["track"] or "未分方向", 0) + 1
|
||||
@@ -211,6 +221,8 @@ def summarize_parse(parsed: dict) -> dict:
|
||||
art += 1
|
||||
if s["leave"]:
|
||||
leave += 1
|
||||
if (s.get("move") or "").strip() or s.get("sheet_kind") in ("art", "leave"):
|
||||
moves += 1
|
||||
for e in exams:
|
||||
if s["scores"].get(e) is not None:
|
||||
exam_present[e] += 1
|
||||
@@ -236,6 +248,7 @@ def summarize_parse(parsed: dict) -> dict:
|
||||
"status": status,
|
||||
"art": art,
|
||||
"leave": leave,
|
||||
"moves": moves,
|
||||
"exam_present": exam_present,
|
||||
"score_scale": score_scale,
|
||||
"sheets": parsed["sheets"],
|
||||
|
||||
@@ -3,80 +3,208 @@ from __future__ import annotations
|
||||
from io import BytesIO
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Alignment, Font, PatternFill, Border, Side
|
||||
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
|
||||
def _style_header(ws, row: int, ncols: int):
|
||||
fill = PatternFill("solid", fgColor="12233D")
|
||||
font = Font(color="F4EFE6", bold=True)
|
||||
for c in range(1, ncols + 1):
|
||||
cell = ws.cell(row, c)
|
||||
cell.fill = fill
|
||||
cell.font = font
|
||||
cell.alignment = Alignment(wrap_text=True, vertical="center")
|
||||
|
||||
|
||||
def _borders(ws, min_row: int = 1, max_row: int | None = None, max_col: int | None = None):
|
||||
thin = Side(style="thin", color="D9D1C3")
|
||||
border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||
max_row = max_row or ws.max_row
|
||||
max_col = max_col or ws.max_column
|
||||
for row in ws.iter_rows(min_row=min_row, max_row=max_row, min_col=1, max_col=max_col):
|
||||
for cell in row:
|
||||
if cell.value is not None:
|
||||
cell.border = border
|
||||
|
||||
|
||||
def _group_order(g: str):
|
||||
return 0 if g == "物理" else 1 if g == "历史" else 2
|
||||
|
||||
|
||||
def _safe_title(text: str) -> str:
|
||||
return text.replace(":", "-").replace("/", "-").replace("\\", "-").replace("?", "-")[:31]
|
||||
|
||||
|
||||
def _raw_sheet(wb: Workbook, group: dict, students: list[dict]):
|
||||
title = _safe_title(f"{group['group']}方向人员")
|
||||
ws = wb.create_sheet(title)
|
||||
exams = group["_exams"]
|
||||
ws.append(["学号", "姓名", "班级", "方向", "选科", "学籍", "异动"] + exams)
|
||||
_style_header(ws, 1, ws.max_column)
|
||||
for s in students:
|
||||
ws.append(
|
||||
[
|
||||
s["sid"], s["name"], s["class_name"], s["track"], s.get("subjects") or "",
|
||||
s.get("status") or "", s.get("move") or "",
|
||||
]
|
||||
+ [("缺考" if s["scores"].get(e) is None else s["scores"].get(e)) for e in exams]
|
||||
)
|
||||
_borders(ws, 2)
|
||||
ws.freeze_panes = "A2"
|
||||
return ws
|
||||
|
||||
|
||||
def _probability_sheet(wb: Workbook, group: dict):
|
||||
title = _safe_title(f"{group['group']}类概率分析")
|
||||
ws = wb.create_sheet(title)
|
||||
exams = group["_exams"]
|
||||
lines = {x["id"]: x for x in group["targets"]}
|
||||
pair_blocks = {x["line"]: x["pairs"] for x in group["pairs"]}
|
||||
r = 1
|
||||
|
||||
def pair_for(line_id: str, a: str, b: str):
|
||||
line_name = lines[line_id]["name"]
|
||||
return next(
|
||||
(p for p in pair_blocks[line_name] if p["from_exam"] == a and p["to_exam"] == b),
|
||||
None,
|
||||
)
|
||||
|
||||
for idx, (a, b) in enumerate(zip(exams, exams[1:]), 1):
|
||||
top_pair = pair_for("top", a, b)
|
||||
control_pair = pair_for("control", a, b)
|
||||
if not top_pair or not control_pair:
|
||||
continue
|
||||
ws.cell(r, 1, f"情况{idx}·尖子线前{lines['top']['k']}名")
|
||||
ws.cell(r, 8, f"情况{idx}·特控线前{lines['control']['k']}名")
|
||||
r += 1
|
||||
for c0, line_id in ((1, "top"), (8, "control")):
|
||||
ws.cell(r, c0, "分层")
|
||||
ws.cell(r, c0 + 1, "排名区间")
|
||||
ws.cell(r, c0 + 2, a)
|
||||
ws.cell(r, c0 + 3, b)
|
||||
ws.cell(r, c0 + 4, "概率")
|
||||
_style_header(ws, r, 12)
|
||||
r += 1
|
||||
max_layers = max(len(top_pair["layers"]), len(control_pair["layers"]))
|
||||
for i in range(max_layers):
|
||||
for c0, pair in ((1, top_pair), (8, control_pair)):
|
||||
if i >= len(pair["layers"]):
|
||||
continue
|
||||
x = pair["layers"][i]
|
||||
ws.cell(r, c0, x["layer"])
|
||||
ws.cell(r, c0 + 1, x.get("label") or f"第{x['layer']}层")
|
||||
ws.cell(r, c0 + 2, x["previous_n"])
|
||||
ws.cell(r, c0 + 3, x["hit_n"])
|
||||
ws.cell(r, c0 + 4, x["probability"])
|
||||
r += 1
|
||||
r += 2
|
||||
|
||||
ws.cell(r, 1, f"汇总·尖子线前{lines['top']['k']}名")
|
||||
ws.cell(r, 8, f"汇总·特控线前{lines['control']['k']}名")
|
||||
r += 1
|
||||
for c0, line_id in ((1, "top"), (8, "control")):
|
||||
ws.cell(r, c0, "分层")
|
||||
ws.cell(r, c0 + 1, "排名区间")
|
||||
ws.cell(r, c0 + 2, "汇总样本")
|
||||
ws.cell(r, c0 + 3, "汇总命中")
|
||||
ws.cell(r, c0 + 4, "合成概率")
|
||||
ws.cell(r, c0 + 5, "拟合概率")
|
||||
_style_header(ws, r, 13)
|
||||
r += 1
|
||||
top_layers = group["summary"]["top"]["layers"]
|
||||
control_layers = group["summary"]["control"]["layers"]
|
||||
for i in range(max(len(top_layers), len(control_layers))):
|
||||
for c0, layers in ((1, top_layers), (8, control_layers)):
|
||||
if i >= len(layers):
|
||||
continue
|
||||
x = layers[i]
|
||||
ws.cell(r, c0, x["layer"])
|
||||
ws.cell(r, c0 + 1, x["label"])
|
||||
ws.cell(r, c0 + 2, x["previous_n"])
|
||||
ws.cell(r, c0 + 3, x["hit_n"])
|
||||
ws.cell(r, c0 + 4, x["probability"])
|
||||
ws.cell(r, c0 + 5, x["fitted_probability"])
|
||||
r += 1
|
||||
r += 1
|
||||
note = (
|
||||
"计算口径:勾选后的每次相邻两次考试都参与比较,最后一次也进入转移样本;同分同名次,后续名次递增;缺考未排除时按0分排名;"
|
||||
"汇总n/k后做加权二项logistic拟合;第1层保留合成概率,首个命中为0的层及其后取0,并强制概率逐层不上升。"
|
||||
)
|
||||
ws.cell(r, 1, note)
|
||||
_borders(ws)
|
||||
ws.column_dimensions["A"].width = 10
|
||||
ws.column_dimensions["H"].width = 10
|
||||
for c in (2, 9):
|
||||
ws.column_dimensions[get_column_letter(c)].width = 24
|
||||
for c in range(3, 8):
|
||||
ws.column_dimensions[get_column_letter(c)].width = 16
|
||||
for c in range(10, 14):
|
||||
ws.column_dimensions[get_column_letter(c)].width = 16
|
||||
return ws
|
||||
|
||||
|
||||
def _metric_sheet(wb: Workbook, group: dict):
|
||||
title = _safe_title(f"{group['group']}指标数")
|
||||
ws = wb.create_sheet(title)
|
||||
r = 1
|
||||
for line in group["targets"]:
|
||||
ws.cell(r, 1, f"{group['group']}方向{line['name']}指标分配")
|
||||
r += 1
|
||||
headers = ["班级"] + [f"{i+1}层人数" for i in range(group["n_layers"])] + ["加权计算", "摊派指标"]
|
||||
ws.cell(r, 1, headers[0])
|
||||
for i, h in enumerate(headers[1:], 2):
|
||||
ws.cell(r, i, h)
|
||||
_style_header(ws, r, len(headers))
|
||||
r += 1
|
||||
ws.cell(r, 1, "排名区间")
|
||||
for i, label in enumerate(line["labels"], 2):
|
||||
ws.cell(r, i, label)
|
||||
r += 1
|
||||
ws.cell(r, 1, "拟合概率")
|
||||
for i, p in enumerate(line["fitted"], 2):
|
||||
ws.cell(r, i, p)
|
||||
r += 1
|
||||
for row in line["classes"]:
|
||||
ws.cell(r, 1, row["class_name"])
|
||||
for i, count in enumerate(row["layer_counts"], 2):
|
||||
ws.cell(r, i, count)
|
||||
ws.cell(r, len(headers) - 1, row["expected"])
|
||||
ws.cell(r, len(headers), row["assigned"])
|
||||
r += 1
|
||||
total_counts = [
|
||||
sum(row["layer_counts"][i] for row in line["classes"])
|
||||
for i in range(group["n_layers"])
|
||||
]
|
||||
ws.cell(r, 1, "合计")
|
||||
for i, count in enumerate(total_counts, 2):
|
||||
ws.cell(r, i, count)
|
||||
ws.cell(r, len(headers) - 1, line["sum_expected"])
|
||||
ws.cell(r, len(headers), line["sum_assigned"])
|
||||
r += 1
|
||||
ws.cell(r, 1, "注:加权计算=Σ(各层人数×拟合概率);摊派指标=加权计算四舍五入。")
|
||||
r += 3
|
||||
ws.column_dimensions["A"].width = 14
|
||||
for c in range(2, ws.max_column + 1):
|
||||
ws.column_dimensions[get_column_letter(c)].width = 13
|
||||
_borders(ws)
|
||||
ws.freeze_panes = "B1"
|
||||
return ws
|
||||
|
||||
|
||||
def build_export(result: dict) -> bytes:
|
||||
wb = Workbook()
|
||||
thin = Border(
|
||||
left=Side(style="thin", color="D9D1C3"),
|
||||
right=Side(style="thin", color="D9D1C3"),
|
||||
top=Side(style="thin", color="D9D1C3"),
|
||||
bottom=Side(style="thin", color="D9D1C3"),
|
||||
)
|
||||
head = PatternFill("solid", fgColor="12233D")
|
||||
head_font = Font(color="F4EFE6", bold=True)
|
||||
wrap = Alignment(wrap_text=True, vertical="center")
|
||||
|
||||
first = True
|
||||
for g in result["groups"]:
|
||||
for t in g["targets"]:
|
||||
title = f"{g['group']}-{t['name']}"[:31]
|
||||
ws = wb.active if first else wb.create_sheet(title)
|
||||
if first:
|
||||
ws.title = title
|
||||
first = False
|
||||
ws.append(
|
||||
["班级", "测算人数", "最近一次在线", "滤波期望", "下达指标", "保底下界", "冲刺上界", "相对最近一次"]
|
||||
)
|
||||
for cell in ws[1]:
|
||||
cell.fill = head
|
||||
cell.font = head_font
|
||||
for row in t["classes"]:
|
||||
ws.append(
|
||||
[
|
||||
row["class_name"],
|
||||
row["n"],
|
||||
row["current"],
|
||||
row["expected"],
|
||||
row["assigned"],
|
||||
row["floor"],
|
||||
row["stretch"],
|
||||
row["delta"],
|
||||
]
|
||||
)
|
||||
ws.append([])
|
||||
ws.append(["说明:下达指标为预估中位水平,班级应努力超越该人数。保底/冲刺来自模拟分布的约20%/80%分位。"])
|
||||
|
||||
ws = wb.create_sheet("学生明细")
|
||||
exams = result["exams"]
|
||||
headers = ["学号", "姓名", "班级", "方向", "学籍", "选科"]
|
||||
headers += [f"{e}-排名" for e in exams]
|
||||
# collect target names
|
||||
tcols = []
|
||||
for g in result["groups"]:
|
||||
for t in g["targets"]:
|
||||
tcols.append((g["group"], t["id"], f"{g['group']}-{t['name']}概率"))
|
||||
headers += [c[2] for c in tcols]
|
||||
headers += ["滤波水平", "预测方差"]
|
||||
ws.append(headers)
|
||||
for cell in ws[1]:
|
||||
cell.fill = head
|
||||
cell.font = head_font
|
||||
cell.alignment = wrap
|
||||
for s in result["students"]:
|
||||
row = [s["sid"], s["name"], s["class_name"], s["group"], s.get("status") or "", s.get("subjects") or ""]
|
||||
ranks = s.get("ranks") or []
|
||||
for i, _e in enumerate(exams):
|
||||
row.append(ranks[i] if i < len(ranks) else None)
|
||||
for gname, tid, _ in tcols:
|
||||
if s["group"] == gname:
|
||||
row.append((s.get("probs") or {}).get(tid))
|
||||
else:
|
||||
row.append(None)
|
||||
row.append(s.get("pred_mean"))
|
||||
row.append(s.get("pred_var"))
|
||||
ws.append(row)
|
||||
wb.remove(wb.active)
|
||||
# Keep the same familiar order as basedata.xlsx: 物理 raw/prob/metric, then 历史.
|
||||
groups = sorted(result["groups"], key=lambda g: _group_order(g["group"]))
|
||||
for i, group in enumerate(groups):
|
||||
students = [s for s in result["students"] if s["group"] == group["group"]]
|
||||
group["_exams"] = result["exams"]
|
||||
_raw_sheet(wb, group, students)
|
||||
for group in groups:
|
||||
_probability_sheet(wb, group)
|
||||
for group in groups:
|
||||
_metric_sheet(wb, group)
|
||||
|
||||
bio = BytesIO()
|
||||
wb.save(bio)
|
||||
|
||||
375
app/layer_method.py
Normal file
375
app/layer_method.py
Normal file
@@ -0,0 +1,375 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
from .excel_parse import class_sort_key
|
||||
|
||||
LAYER_SIZE = 20
|
||||
MISSING_AS_SCORE = 0.0
|
||||
|
||||
|
||||
def infer_track_group(track: str) -> str:
|
||||
t = track or ""
|
||||
if "历史" in t:
|
||||
return "历史"
|
||||
if "物理" in t:
|
||||
return "物理"
|
||||
return t or "未分方向"
|
||||
|
||||
|
||||
def default_line_settings(groups: list[str]) -> dict[str, dict[str, int]]:
|
||||
out = {}
|
||||
for g in groups:
|
||||
if g == "物理":
|
||||
out[g] = {"top": 80, "control": 262, "layer_size": 20}
|
||||
elif g == "历史":
|
||||
# 历史样本较小;basedata.xlsx 的历史方向使用 5 人一层。
|
||||
out[g] = {"top": 10, "control": 45, "layer_size": 5}
|
||||
else:
|
||||
out[g] = {"top": 20, "control": 70, "layer_size": 20}
|
||||
return out
|
||||
|
||||
|
||||
def select_students(parsed: dict, settings: dict) -> list[dict]:
|
||||
include_mingyuan = settings.get("include_mingyuan", True)
|
||||
include_other = settings.get("include_other", True)
|
||||
exclude_moves = bool(settings.get("exclude_moves", False))
|
||||
exams = settings.get("exams") or parsed["exams"]
|
||||
selected = []
|
||||
for s in parsed["students"]:
|
||||
move_flag = bool((s.get("move") or "").strip()) or s.get("sheet_kind") in ("art", "leave")
|
||||
if exclude_moves and move_flag:
|
||||
continue
|
||||
st = s.get("status") or "在籍"
|
||||
if st == "明远" and not include_mingyuan:
|
||||
continue
|
||||
if st not in ("在籍", "明远", "") and not include_other:
|
||||
continue
|
||||
row = dict(s)
|
||||
row["move_flag"] = move_flag
|
||||
row["scores"] = {e: s["scores"].get(e) for e in exams}
|
||||
row["computed_scores"] = {
|
||||
e: MISSING_AS_SCORE if v is None else float(v) for e, v in row["scores"].items()
|
||||
}
|
||||
row["missing_exams"] = [e for e, v in row["scores"].items() if v is None]
|
||||
row["group"] = infer_track_group(s.get("track") or "")
|
||||
selected.append(row)
|
||||
return selected
|
||||
|
||||
|
||||
def competition_ranks(values: list[float]) -> list[int]:
|
||||
"""1 = highest; all equal scores receive the same rank and later ranks are skipped."""
|
||||
return [1 + sum(1 for v in values if v > x) for x in values]
|
||||
|
||||
|
||||
def layer_count(n: int, size: int = LAYER_SIZE) -> int:
|
||||
return max(1, math.ceil(n / size)) if n else 1
|
||||
|
||||
|
||||
def rank_layer(rank: int, size: int = LAYER_SIZE) -> int:
|
||||
"""A tie at a boundary stays in the upper layer because the next distinct rank is skipped."""
|
||||
return max(1, (int(rank) - 1) // size + 1)
|
||||
|
||||
|
||||
def layer_label(layer: int, n: int, size: int = LAYER_SIZE) -> str:
|
||||
start = (layer - 1) * size + 1
|
||||
if layer * size >= n:
|
||||
return f"前{start}名及以后"
|
||||
return f"前{start}-{layer * size}名"
|
||||
|
||||
|
||||
def _sigmoid(x: float) -> float:
|
||||
if x >= 0:
|
||||
return 1.0 / (1.0 + math.exp(-min(x, 40.0)))
|
||||
z = math.exp(max(x, -40.0))
|
||||
return z / (1.0 + z)
|
||||
|
||||
|
||||
def _log_likelihood(beta: list[float], rows: list[tuple[float, float, float]]) -> float:
|
||||
total = 0.0
|
||||
for t, n, k in rows:
|
||||
p = _sigmoid(beta[0] - beta[1] * t)
|
||||
p = min(max(p, 1e-10), 1 - 1e-10)
|
||||
total += k * math.log(p) + (n - k) * math.log(1 - p)
|
||||
return total
|
||||
|
||||
|
||||
def fit_retention_probabilities(
|
||||
counts: list[dict], round_digits: int = 6
|
||||
) -> list[float]:
|
||||
"""Weighted binomial logistic fit: p=1/(1+exp(-(a-b*layer)))."""
|
||||
rows = [(float(x["layer"]), float(x["previous_n"]), float(x["hit_n"])) for x in counts]
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
beta = [0.3, 0.15]
|
||||
best = _log_likelihood(beta, rows)
|
||||
for _ in range(100):
|
||||
g1 = g2 = 0.0
|
||||
h11 = h12 = h22 = 0.0
|
||||
for t, n, k in rows:
|
||||
p = _sigmoid(beta[0] - beta[1] * t)
|
||||
w = n * p * (1 - p)
|
||||
r = k - n * p
|
||||
xt1, xt2 = 1.0, -t
|
||||
g1 += xt1 * r
|
||||
g2 += xt2 * r
|
||||
h11 += w * xt1 * xt1
|
||||
h12 += w * xt1 * xt2
|
||||
h22 += w * xt2 * xt2
|
||||
# A tiny ridge prevents singular matrices when a layer is perfectly separated.
|
||||
h11 += 1e-7
|
||||
h22 += 1e-7
|
||||
det = h11 * h22 - h12 * h12
|
||||
if abs(det) < 1e-14:
|
||||
break
|
||||
d1 = (h22 * g1 - h12 * g2) / det
|
||||
d2 = (h11 * g2 - h12 * g1) / det
|
||||
step = 1.0
|
||||
improved = False
|
||||
for _ls in range(20):
|
||||
cand = [beta[0] + step * d1, beta[1] + step * d2]
|
||||
# Keep the curve monotone decreasing and numerically bounded.
|
||||
cand[0] = min(max(cand[0], -6.0), 14.0)
|
||||
cand[1] = min(max(cand[1], 0.0), 10.0)
|
||||
value = _log_likelihood(cand, rows)
|
||||
if value + 1e-9 >= best:
|
||||
beta, best = cand, value
|
||||
improved = True
|
||||
break
|
||||
step *= 0.5
|
||||
if not improved or math.sqrt(d1 * d1 + d2 * d2) < 1e-8:
|
||||
break
|
||||
|
||||
raw = [_sigmoid(beta[0] - beta[1] * t) for t, _, _ in rows]
|
||||
|
||||
# basedata.xlsx 的约定:第 1 层不使用 logistic 估计值,而是保留合成概率。
|
||||
# 这样小样本首层的边界观测不会被模型收缩。
|
||||
if counts and counts[0]["previous_n"]:
|
||||
raw[0] = counts[0]["hit_n"] / counts[0]["previous_n"]
|
||||
|
||||
# basedata.xlsx 的尾部约定:第一次出现命中为 0 的层及其后,拟合概率取 0。
|
||||
first_zero = next((i for i, item in enumerate(counts) if item["hit_n"] == 0), None)
|
||||
if first_zero is not None:
|
||||
raw[first_zero:] = [0.0] * (len(raw) - first_zero)
|
||||
|
||||
# Force monotone decreasing, even when sparse layers reverse the fitted trend.
|
||||
for i in range(1, len(raw)):
|
||||
raw[i] = min(raw[i], raw[i - 1])
|
||||
return [round(max(0.0, min(1.0, p)), round_digits) for p in raw]
|
||||
|
||||
|
||||
def round_half_up(value: float) -> int:
|
||||
return int(Decimal(str(float(value))).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
def _pair_counts(
|
||||
members: list[dict], e0: str, e1: str, threshold: int, size: int = LAYER_SIZE
|
||||
) -> list[dict]:
|
||||
total_layers = layer_count(len(members), size)
|
||||
buckets = [
|
||||
{"layer": i, "previous_n": 0, "hit_n": 0} for i in range(1, total_layers + 1)
|
||||
]
|
||||
for s in members:
|
||||
layer = rank_layer(s["ranks"][e0], size)
|
||||
buckets[layer - 1]["previous_n"] += 1
|
||||
if s["ranks"][e1] <= threshold:
|
||||
buckets[layer - 1]["hit_n"] += 1
|
||||
return buckets
|
||||
|
||||
|
||||
def _merge_counts(parts: list[list[dict]]) -> list[dict]:
|
||||
n_layers = max(len(p) for p in parts)
|
||||
out = [{"layer": i, "previous_n": 0, "hit_n": 0} for i in range(1, n_layers + 1)]
|
||||
for part in parts:
|
||||
for item in part:
|
||||
out[item["layer"] - 1]["previous_n"] += item["previous_n"]
|
||||
out[item["layer"] - 1]["hit_n"] += item["hit_n"]
|
||||
return out
|
||||
|
||||
|
||||
def analyze(parsed: dict, settings: dict) -> dict:
|
||||
exams = settings.get("exams") or parsed["exams"]
|
||||
if len(exams) < 2:
|
||||
raise ValueError("相邻层次法至少需要两次考试")
|
||||
selected = select_students(parsed, settings)
|
||||
if not selected:
|
||||
raise ValueError("没有学生进入测算")
|
||||
groups = sorted({s["group"] for s in selected})
|
||||
line_settings = settings.get("line_settings") or default_line_settings(groups)
|
||||
|
||||
# 用户口径:勾选后的所有相邻两次都要计算,最后一次也进入相邻转移样本。
|
||||
probability_exams = exams
|
||||
|
||||
# 排名与层次划分;缺考已按 0 分参加排名。
|
||||
for s in selected:
|
||||
s["ranks"] = {}
|
||||
s["layers"] = {}
|
||||
for e in exams:
|
||||
vals = [s["computed_scores"][e] for s in selected]
|
||||
# Rankings are global within each direction, so calculate by group.
|
||||
for g in groups:
|
||||
gm = [s for s in selected if s["group"] == g]
|
||||
gvals = [s["computed_scores"][e] for s in gm]
|
||||
ranks = competition_ranks(gvals)
|
||||
for s, rank in zip(gm, ranks):
|
||||
s["ranks"][e] = rank
|
||||
s["layers"][e] = rank_layer(rank)
|
||||
|
||||
results = []
|
||||
all_views = []
|
||||
for g in groups:
|
||||
members = [s for s in selected if s["group"] == g]
|
||||
n = len(members)
|
||||
lines = line_settings.get(g) or default_line_settings([g])[g]
|
||||
top_k = max(1, min(int(lines.get("top", 1)), n))
|
||||
control_k = max(top_k, min(int(lines.get("control", top_k)), n))
|
||||
line_defs = [
|
||||
{"id": "top", "name": "尖子线", "k": top_k},
|
||||
{"id": "control", "name": "特控线", "k": control_k},
|
||||
]
|
||||
layer_size = max(1, min(int(lines.get("layer_size", LAYER_SIZE)), max(1, n)))
|
||||
n_layers = layer_count(n, layer_size)
|
||||
labels = [layer_label(i, n, layer_size) for i in range(1, n_layers + 1)]
|
||||
pair_blocks = []
|
||||
summary_lines = {}
|
||||
target_blocks = []
|
||||
for line in line_defs:
|
||||
pair_parts = []
|
||||
pair_details = []
|
||||
pair_labels = [layer_label(i, n, layer_size) for i in range(1, n_layers + 1)]
|
||||
for a, b in zip(probability_exams, probability_exams[1:]):
|
||||
part = _pair_counts(members, a, b, line["k"], layer_size)
|
||||
pair_parts.append(part)
|
||||
pair_details.append(
|
||||
{
|
||||
"from_exam": a,
|
||||
"to_exam": b,
|
||||
"layers": [
|
||||
{
|
||||
**x,
|
||||
"label": pair_labels[x["layer"] - 1],
|
||||
"probability": (
|
||||
x["hit_n"] / x["previous_n"]
|
||||
if x["previous_n"]
|
||||
else None
|
||||
),
|
||||
}
|
||||
for x in part
|
||||
],
|
||||
}
|
||||
)
|
||||
merged = _merge_counts(pair_parts)
|
||||
for item, label in zip(merged, labels):
|
||||
item["label"] = label
|
||||
item["probability"] = (
|
||||
item["hit_n"] / item["previous_n"]
|
||||
if item["previous_n"]
|
||||
else None
|
||||
)
|
||||
fitted = fit_retention_probabilities(merged)
|
||||
for item, p in zip(merged, fitted):
|
||||
item["fitted_probability"] = p
|
||||
|
||||
classes = sorted({s["class_name"] for s in members}, key=class_sort_key)
|
||||
class_rows = []
|
||||
for c in classes:
|
||||
cm = [s for s in members if s["class_name"] == c]
|
||||
layer_counts = [0] * n_layers
|
||||
for s in cm:
|
||||
layer_counts[rank_layer(s["ranks"][exams[-1]], layer_size) - 1] += 1
|
||||
expected = sum(a * p for a, p in zip(layer_counts, fitted))
|
||||
current = sum(1 for s in cm if s["ranks"][exams[-1]] <= line["k"])
|
||||
class_rows.append(
|
||||
{
|
||||
"class_name": c,
|
||||
"n": len(cm),
|
||||
"current": current,
|
||||
"layer_counts": layer_counts,
|
||||
"expected": round(expected, 4),
|
||||
"assigned": round_half_up(expected),
|
||||
"delta": round_half_up(expected) - current,
|
||||
}
|
||||
)
|
||||
summary_lines[line["id"]] = {
|
||||
"layers": merged,
|
||||
"labels": labels,
|
||||
"total_previous": sum(x["previous_n"] for x in merged),
|
||||
"total_hit": sum(x["hit_n"] for x in merged),
|
||||
"fitted": fitted,
|
||||
}
|
||||
target_blocks.append(
|
||||
{
|
||||
**line,
|
||||
"classes": class_rows,
|
||||
"layers": merged,
|
||||
"labels": labels,
|
||||
"fitted": fitted,
|
||||
"sum_expected": round(sum(r["expected"] for r in class_rows), 4),
|
||||
"sum_assigned": sum(r["assigned"] for r in class_rows),
|
||||
"sum_current": sum(r["current"] for r in class_rows),
|
||||
}
|
||||
)
|
||||
pair_blocks.append({"line": line["name"], "pairs": pair_details})
|
||||
|
||||
results.append(
|
||||
{
|
||||
"group": g,
|
||||
"n": n,
|
||||
"layer_size": layer_size,
|
||||
"n_layers": n_layers,
|
||||
"line_settings": {"top": top_k, "control": control_k},
|
||||
"pairs": pair_blocks,
|
||||
"summary": summary_lines,
|
||||
"targets": target_blocks,
|
||||
}
|
||||
)
|
||||
|
||||
for s in selected:
|
||||
all_views.append(
|
||||
{
|
||||
"sid": s["sid"],
|
||||
"name": s["name"],
|
||||
"class_name": s["class_name"],
|
||||
"group": s["group"],
|
||||
"track": s.get("track") or "",
|
||||
"subjects": s.get("subjects") or "",
|
||||
"status": s.get("status") or "",
|
||||
"move": s.get("move") or "",
|
||||
"has_move": bool(s.get("move_flag", False)),
|
||||
"scores": s["scores"],
|
||||
"computed_scores": s["computed_scores"],
|
||||
"missing_exams": s["missing_exams"],
|
||||
"ranks": s["ranks"],
|
||||
"layers": s["layers"],
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"algorithm": "adjacent-layer-markov-v2",
|
||||
"exams": exams,
|
||||
"layer_size": LAYER_SIZE,
|
||||
"pair_count": len(probability_exams) - 1 if len(probability_exams) > 1 else 0,
|
||||
"probability_exams": probability_exams,
|
||||
"missing_as_score": MISSING_AS_SCORE,
|
||||
"exclude_moves": bool(settings.get("exclude_moves", False)),
|
||||
"n_selected": len(selected),
|
||||
"n_total": len(parsed["students"]),
|
||||
"groups": results,
|
||||
"students": all_views,
|
||||
}
|
||||
|
||||
|
||||
def class_comment(row: dict, target_name: str) -> str:
|
||||
assigned = row["assigned"]
|
||||
current = row["current"]
|
||||
expected = row["expected"]
|
||||
if assigned > current:
|
||||
tail = f"比最后一次到线人数多 {assigned - current} 人。"
|
||||
elif assigned < current:
|
||||
tail = f"比最后一次到线人数少 {current - assigned} 人。"
|
||||
else:
|
||||
tail = "与最后一次到线人数持平。"
|
||||
return f"{row['class_name']}班{target_name}拟合摊派 {assigned} 人(加权计算 {expected:.2f})。{tail}"
|
||||
72
app/main.py
72
app/main.py
@@ -32,9 +32,9 @@ from .db import (
|
||||
)
|
||||
from .excel_parse import parse_workbook, summarize_parse
|
||||
from .export_xlsx import build_export
|
||||
from .indicators import analyze, class_comment, default_targets, infer_track_group, select_students
|
||||
from .layer_method import analyze, class_comment, default_line_settings, infer_track_group, select_students
|
||||
|
||||
app = FastAPI(title="航标 · 班级达线指标预估")
|
||||
app = FastAPI(title="指标测算 · 相邻层次法")
|
||||
app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY, max_age=60 * 60 * 12)
|
||||
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
|
||||
@@ -136,17 +136,20 @@ def demo(request: Request):
|
||||
def _settings_from(parsed: dict, stored: dict | None) -> dict:
|
||||
summary = summarize_parse(parsed)
|
||||
groups = sorted({infer_track_group(s["track"]) for s in parsed["students"]})
|
||||
line_settings = default_line_settings(groups)
|
||||
base = {
|
||||
"exams": parsed["exams"],
|
||||
"include_art": False,
|
||||
"include_leave": False,
|
||||
"exclude_moves": False,
|
||||
"include_mingyuan": True,
|
||||
"include_other": True,
|
||||
"horizon": 1,
|
||||
"targets": default_targets(groups),
|
||||
"line_settings": line_settings,
|
||||
}
|
||||
if stored:
|
||||
for k in ("exams", "include_art", "include_leave", "include_mingyuan", "include_other", "horizon", "targets"):
|
||||
# 考试列必须始终来自本次上传的工作簿,避免旧分析设置把不存在的列带进来。
|
||||
valid_exam_set = set(parsed["exams"])
|
||||
stored_exams = [e for e in (stored.get("exams") or []) if e in valid_exam_set]
|
||||
base["exams"] = stored_exams or parsed["exams"]
|
||||
for k in ("exclude_moves", "include_mingyuan", "include_other", "line_settings"):
|
||||
if k in stored:
|
||||
base[k] = stored[k]
|
||||
base["summary"] = summary
|
||||
@@ -154,6 +157,7 @@ def _settings_from(parsed: dict, stored: dict | None) -> dict:
|
||||
return base
|
||||
|
||||
|
||||
|
||||
@app.get("/analysis/{aid}", response_class=HTMLResponse)
|
||||
def analysis_page(request: Request, aid: int, step: int = 1):
|
||||
if (redir := require_login(request)):
|
||||
@@ -189,34 +193,39 @@ async def save_settings(request: Request, aid: int):
|
||||
return HTMLResponse("分析不存在", status_code=404)
|
||||
parsed = load_json(item["parsed_path"])
|
||||
form = await request.form()
|
||||
exams = form.getlist("exams")
|
||||
valid_exam_set = set(parsed["exams"])
|
||||
exams = [e for e in form.getlist("exams") if e in valid_exam_set]
|
||||
if not exams:
|
||||
exams = parsed["exams"]
|
||||
if len(exams) < 2:
|
||||
return HTMLResponse("至少需要勾选两次考试才能计算相邻转移概率。", status_code=400)
|
||||
groups = sorted({infer_track_group(s["track"]) for s in parsed["students"]})
|
||||
targets = {}
|
||||
defaults = default_line_settings(groups)
|
||||
line_settings = {}
|
||||
for g in groups:
|
||||
names = form.getlist(f"t_name_{g}")
|
||||
ks = form.getlist(f"t_k_{g}")
|
||||
ids = form.getlist(f"t_id_{g}")
|
||||
rows = []
|
||||
for i, name in enumerate(names):
|
||||
k_raw = ks[i] if i < len(ks) else "0"
|
||||
try:
|
||||
k = int(float(k_raw))
|
||||
top = int(float(form.get(f"top_k_{g}", defaults[g]["top"])))
|
||||
except ValueError:
|
||||
k = 0
|
||||
tid = ids[i] if i < len(ids) and ids[i] else f"t{i+1}"
|
||||
if name.strip() and k > 0:
|
||||
rows.append({"id": tid, "name": name.strip(), "k": k})
|
||||
targets[g] = rows or default_targets([g])[g]
|
||||
top = defaults[g]["top"]
|
||||
try:
|
||||
control = int(float(form.get(f"control_k_{g}", defaults[g]["control"])))
|
||||
except ValueError:
|
||||
control = defaults[g]["control"]
|
||||
try:
|
||||
layer_size = int(float(form.get(f"layer_size_{g}", defaults[g]["layer_size"])))
|
||||
except ValueError:
|
||||
layer_size = defaults[g]["layer_size"]
|
||||
line_settings[g] = {
|
||||
"top": max(1, top),
|
||||
"control": max(1, max(top, control)),
|
||||
"layer_size": max(1, layer_size),
|
||||
}
|
||||
settings = {
|
||||
"exams": list(exams),
|
||||
"include_art": form.get("include_art") == "on",
|
||||
"include_leave": form.get("include_leave") == "on",
|
||||
"exclude_moves": form.get("exclude_moves") == "on",
|
||||
"include_mingyuan": form.get("include_mingyuan") == "on",
|
||||
"include_other": form.get("include_other") == "on",
|
||||
"horizon": int(form.get("horizon") or 1),
|
||||
"targets": targets,
|
||||
"line_settings": line_settings,
|
||||
}
|
||||
update_analysis(aid, settings_json=json.dumps(settings, ensure_ascii=False), step=2)
|
||||
action = form.get("action") or "next"
|
||||
@@ -230,6 +239,7 @@ async def save_settings(request: Request, aid: int):
|
||||
return RedirectResponse(f"/analysis/{aid}?step=2", status_code=303)
|
||||
|
||||
|
||||
|
||||
@app.get("/analysis/{aid}/api/student/{sid}")
|
||||
def student_api(request: Request, aid: int, sid: str):
|
||||
if not current_user(request):
|
||||
@@ -257,8 +267,14 @@ def class_api(request: Request, aid: int, group: str, target: str, class_name: s
|
||||
for s in result["students"]
|
||||
if s["group"] == group and s["class_name"] == class_name
|
||||
]
|
||||
rows.sort(key=lambda s: -((s.get("probs") or {}).get(target) or 0))
|
||||
return {"exams": result["exams"], "students": rows[:80], "n": len(rows)}
|
||||
rows.sort(key=lambda s: (s.get("ranks") or {}).get(result["exams"][-1], 10**9))
|
||||
return {
|
||||
"exams": result["exams"],
|
||||
"layer_size": result.get("layer_size"),
|
||||
"last_exam": result["exams"][-1],
|
||||
"students": rows,
|
||||
"n": len(rows),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/analysis/{aid}/export")
|
||||
@@ -269,6 +285,8 @@ def export_analysis(request: Request, aid: int):
|
||||
if not item or not item.get("result_path"):
|
||||
return HTMLResponse("请先完成测算", status_code=400)
|
||||
result = load_json(item["result_path"])
|
||||
if result.get("algorithm") != "adjacent-layer-markov-v2":
|
||||
return HTMLResponse("结果为旧算法,请回到第二步重新测算后再导出。", status_code=400)
|
||||
data = build_export(result)
|
||||
ascii_name = f"hangbiao-{aid}.xlsx"
|
||||
star = quote(f"{item['title']}-下达指标.xlsx")
|
||||
|
||||
@@ -147,3 +147,7 @@ input[type=text], input[type=password], input[type=number], select {
|
||||
.hero, .layout, .grid-2, .kpis { grid-template-columns: 1fr; }
|
||||
.rail { position: static; }
|
||||
}
|
||||
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
|
||||
details { border: 1px solid var(--line); border-radius: 14px; background: #fff; padding: 10px 14px; margin: 10px 0; }
|
||||
details summary { cursor: pointer; font-size: 14px; font-weight: 600; }
|
||||
@media (max-width: 900px) { .grid-3 { grid-template-columns: 1fr; } }
|
||||
|
||||
@@ -3,77 +3,42 @@ function closeDrawer() {
|
||||
if (el) el.classList.remove("show");
|
||||
}
|
||||
|
||||
function sparkline(svg, exams, zs, xs, lastRank) {
|
||||
const w = 520, h = 220, pad = 28;
|
||||
const vals = [];
|
||||
zs.forEach((z, i) => { if (z !== null && z !== undefined) vals.push(z); });
|
||||
xs.forEach((z) => { if (z !== null && z !== undefined) vals.push(z); });
|
||||
if (!vals.length) return;
|
||||
const min = Math.min(...vals, -0.5) - 0.3;
|
||||
const max = Math.max(...vals, 0.5) + 0.3;
|
||||
const xAt = (i) => pad + (i * (w - pad * 2)) / Math.max(exams.length - 1, 1);
|
||||
const yAt = (v) => pad + (max - v) * (h - pad * 2) / (max - min);
|
||||
const pts = (arr) => arr.map((v, i) => v == null ? null : `${xAt(i)},${yAt(v)}`).filter(Boolean).join(" ");
|
||||
let raw = "";
|
||||
zs.forEach((v, i) => {
|
||||
if (v == null) return;
|
||||
raw += `<circle cx="${xAt(i)}" cy="${yAt(v)}" r="4.5" fill="#c47b17"></circle>`;
|
||||
});
|
||||
const pathXs = xs.map((v, i) => (v == null ? null : [xAt(i), yAt(v)])).filter(Boolean);
|
||||
let d = "";
|
||||
pathXs.forEach((p, i) => { d += (i === 0 ? `M${p[0]} ${p[1]}` : ` L${p[0]} ${p[1]}`); });
|
||||
const labels = exams.map((e, i) => {
|
||||
const short = String(e).replace("-总分", "").replace("高", "");
|
||||
return `<text x="${xAt(i)}" y="${h - 6}" text-anchor="middle" font-size="10" fill="#3a4a63">${short}</text>`;
|
||||
}).join("");
|
||||
svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
|
||||
svg.innerHTML = `
|
||||
<line x1="${pad}" y1="${yAt(0)}" x2="${w - pad}" y2="${yAt(0)}" stroke="#d8cfbf" stroke-dasharray="4 4"></line>
|
||||
<path d="${d}" fill="none" stroke="#1c6f63" stroke-width="2.4"></path>
|
||||
${raw}
|
||||
${labels}
|
||||
<text x="${pad}" y="16" font-size="11" fill="#3a4a63">标准分轨迹 · 橙点考试,绿线滤波估计</text>
|
||||
`;
|
||||
function esc(value) {
|
||||
return String(value ?? "").replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||||
}
|
||||
|
||||
async function openStudent(aid, sid) {
|
||||
const res = await fetch(`/analysis/${aid}/api/student/${encodeURIComponent(sid)}`);
|
||||
if (!res.ok) return;
|
||||
const s = await res.json();
|
||||
const exams = window.HB_EXAMS || [];
|
||||
const rows = exams.map((exam, i) => {
|
||||
const score = (s.scores || {})[exam];
|
||||
const computed = (s.computed_scores || {})[exam];
|
||||
const rank = (s.ranks || {})[exam];
|
||||
const layer = (s.layers || {})[exam];
|
||||
return `<tr>
|
||||
<td>${esc(exam)}</td>
|
||||
<td>${score == null ? "缺考(按 0 分)" : esc(score)}</td>
|
||||
<td>${rank ?? "—"}</td><td>${layer ?? "—"}</td>
|
||||
<td>${computed ?? "—"}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
const box = document.getElementById("drawer");
|
||||
const body = document.getElementById("drawer-body");
|
||||
const probs = Object.entries(s.probs || {}).map(([k, v]) => `${k}: ${(v * 100).toFixed(1)}%`).join(" ");
|
||||
body.innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:start">
|
||||
<div>
|
||||
<h2 style="margin:0">${s.name} <span class="tiny">${s.sid}</span></h2>
|
||||
<div class="tiny">${s.class_name}班 · ${s.group} · ${s.status || "在籍"} · ${s.subjects || ""}</div>
|
||||
<h2 style="margin:0">${esc(s.name)} <span class="tiny">${esc(s.sid)}</span></h2>
|
||||
<div class="tiny">${esc(s.class_name)}班 · ${esc(s.group)} · ${esc(s.status || "在籍")} · ${esc(s.subjects || "")}</div>
|
||||
${s.move ? `<p class="tiny">异动/备注:${esc(s.move)}</p>` : ""}
|
||||
</div>
|
||||
<button class="ghost" onclick="closeDrawer()">关闭</button>
|
||||
</div>
|
||||
<p class="explain" style="margin-top:14px">
|
||||
绿线是卡尔曼估计的稳定水平。如果橙点突然跳开绿线,系统会当成发挥波动,不会立刻改判断。
|
||||
达线概率 ${probs || "—"}。
|
||||
</p>
|
||||
<svg class="spark" id="spark"></svg>
|
||||
<table class="table">
|
||||
<thead><tr><th>考试</th><th>分数</th><th>方向内名次</th><th>标准分</th><th>滤波</th></tr></thead>
|
||||
<tbody id="spark-rows"></tbody>
|
||||
</table>
|
||||
<table class="table"><thead>
|
||||
<tr><th>考试</th><th>原始分</th><th>方向名次</th><th>层次</th><th>计算分</th></tr>
|
||||
</thead><tbody>${rows}</tbody></table>
|
||||
`;
|
||||
const rows = document.getElementById("spark-rows");
|
||||
(s.z || []).forEach((z, i) => {
|
||||
const exam = (window.HB_EXAMS || [])[i] || `第${i + 1}次`;
|
||||
const score = s.scores ? s.scores[exam] : "";
|
||||
const rank = (s.ranks || [])[i];
|
||||
const kf = (s.kf_xs || [])[i];
|
||||
rows.insertAdjacentHTML("beforeend", `<tr>
|
||||
<td>${exam}</td><td>${score ?? "缺考"}</td><td>${rank ?? "—"}</td>
|
||||
<td>${z == null ? "—" : z.toFixed(2)}</td>
|
||||
<td>${kf == null ? "—" : kf.toFixed(2)}</td>
|
||||
</tr>`);
|
||||
});
|
||||
sparkline(document.getElementById("spark"), window.HB_EXAMS || [], s.z || [], s.kf_xs || []);
|
||||
box.classList.add("show");
|
||||
}
|
||||
|
||||
@@ -82,27 +47,24 @@ async function openClass(aid, group, target, className) {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const box = document.getElementById("drawer");
|
||||
const body = document.getElementById("drawer-body");
|
||||
const rows = data.students.map((s) => {
|
||||
const p = ((s.probs || {})[target] || 0) * 100;
|
||||
const tag = p >= 80 ? "high" : p >= 20 ? "mid" : "";
|
||||
return `<tr class="clickable" onclick="openStudent(${aid}, '${s.sid}')">
|
||||
<td>${s.name}</td><td>${s.status || ""}</td>
|
||||
<td>${(s.ranks || []).slice(-1)[0] ?? "—"}</td>
|
||||
<td><span class="tag ${tag}">${p.toFixed(1)}%</span></td>
|
||||
const lastExam = data.last_exam || (data.exams || []).slice(-1)[0];
|
||||
const rows = (data.students || []).map((s) => {
|
||||
const rank = (s.ranks || {})[lastExam];
|
||||
const layer = (s.layers || {})[lastExam];
|
||||
return `<tr class="clickable" onclick="openStudent(${aid}, '${esc(s.sid)}')">
|
||||
<td>${esc(s.name)}</td><td>${esc(s.status || "")}</td>
|
||||
<td>${rank ?? "—"}</td><td>${layer ?? "—"}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
const box = document.getElementById("drawer");
|
||||
const body = document.getElementById("drawer-body");
|
||||
body.innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between">
|
||||
<h2 style="margin:0">${className}班学生概率</h2>
|
||||
<h2 style="margin:0">${esc(className)}班 · ${esc(lastExam || "")}</h2>
|
||||
<button class="ghost" onclick="closeDrawer()">关闭</button>
|
||||
</div>
|
||||
<p class="tiny">点击学生可看成绩轨迹。共 ${data.n} 人,下列按达线概率排序。</p>
|
||||
<table class="table">
|
||||
<thead><tr><th>姓名</th><th>学籍</th><th>最近名次</th><th>达线概率</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
<p class="tiny">按最后一次方向名次排序,共 ${data.n} 人。</p>
|
||||
<table class="table"><thead><tr><th>姓名</th><th>学籍</th><th>名次</th><th>层次</th></tr></thead><tbody>${rows}</tbody></table>
|
||||
`;
|
||||
box.classList.add("show");
|
||||
}
|
||||
|
||||
@@ -3,30 +3,31 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ item.title }} · 航标</title>
|
||||
<title>{{ item.title }} · 相邻层次法</title>
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/">
|
||||
<div class="mark">航</div>
|
||||
<div><b>航标</b><span>{{ item.title }}</span></div>
|
||||
<div class="mark">层</div>
|
||||
<div><b>指标测算</b><span>{{ item.title }}</span></div>
|
||||
</a>
|
||||
<div style="display:flex;gap:8px">
|
||||
{% if result %}
|
||||
<a class="ghost" href="/analysis/{{ item.id }}/export">导出 Excel</a>
|
||||
{% if result and result.get('algorithm') == 'adjacent-layer-markov-v2' %}
|
||||
<a class="ghost" href="/analysis/{{ item.id }}/export">导出总表</a>
|
||||
{% endif %}
|
||||
<form action="/logout" method="post"><button class="ghost" type="submit">退出</button></form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="wrap layout">
|
||||
<aside class="rail">
|
||||
<h2>分析航线</h2>
|
||||
<h2>测算航线</h2>
|
||||
{% set steps = [
|
||||
(1, '数据体检', '看清表格里有谁、有哪些考试'),
|
||||
(2, '口径与目标', '决定算谁、瞄哪条线'),
|
||||
(3, '滤波与回测', '理解预估为什么可信'),
|
||||
(4, '下达指标', '班级目标、临界生、轨迹')
|
||||
(1, '数据体检', '确认学生、考试、缺考'),
|
||||
(2, '口径与线', '排除异动、改尖子/特控线'),
|
||||
(3, '相邻命中概率', '逐对考试汇总并拟合'),
|
||||
(4, '指标摊派', '最后一次成绩分班下达')
|
||||
] %}
|
||||
{% for n, title, desc in steps %}
|
||||
<a class="step {% if step == n %}on{% endif %}" href="/analysis/{{ item.id }}?step={{ n }}">
|
||||
@@ -39,12 +40,12 @@
|
||||
<section class="card panel">
|
||||
{% if step == 1 %}
|
||||
<h1>第一步:成绩表体检</h1>
|
||||
<p class="muted">先确认系统读到的是你想算的那批人。分数量纲可以不同,系统稍后会在每次考试内部标准化。</p>
|
||||
<p class="muted">这里只检查源表识别结果。缺考学生在后续测算中会按 0 分参加同方向排名。</p>
|
||||
<div class="kpis">
|
||||
<div class="kpi"><em>识别学生</em><strong>{{ summary.n }}</strong></div>
|
||||
<div class="kpi"><em>班级</em><strong>{{ summary.n_class }}</strong></div>
|
||||
<div class="kpi"><em>考试次数</em><strong>{{ summary.exams|length }}</strong></div>
|
||||
<div class="kpi"><em>艺体 / 请假类</em><strong>{{ summary.art }} / {{ summary.leave }}</strong></div>
|
||||
<div class="kpi"><em>异动备注</em><strong>{{ summary.moves }}</strong></div>
|
||||
</div>
|
||||
{% for w in summary.warnings %}
|
||||
<div class="warn">{{ w }}</div>
|
||||
@@ -53,211 +54,197 @@
|
||||
<div>
|
||||
<h3>工作表</h3>
|
||||
<table class="table">
|
||||
<thead><tr><th>表名</th><th>类型</th><th>行数</th></tr></thead>
|
||||
<thead><tr><th>名称</th><th>类型</th><th>行数</th></tr></thead>
|
||||
<tbody>
|
||||
{% for sh in summary.sheets %}
|
||||
<tr><td>{{ sh.name }}</td><td>{{ sh.kind }}</td><td>{{ sh.rows }}</td></tr>
|
||||
{% for s in summary.sheets %}
|
||||
<tr><td>{{ s.name }}</td><td>{{ s.kind }}</td><td>{{ s.rows }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<h3>方向 / 学籍</h3>
|
||||
<div class="chips">
|
||||
{% for k,v in summary.tracks.items() %}<span class="chip">{{ k or "未分方向" }} {{ v }}</span>{% endfor %}
|
||||
</div>
|
||||
<div class="chips">
|
||||
{% for k,v in summary.status.items() %}<span class="chip">{{ k }} {{ v }}</span>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3>考试识别与分数尺度</h3>
|
||||
<h3>考试识别</h3>
|
||||
<table class="table">
|
||||
<thead><tr><th>考试</th><th>有效人数</th><th>最低</th><th>最高</th><th>平均</th></tr></thead>
|
||||
<thead><tr><th>考试</th><th>有分人数</th><th>最低</th><th>最高</th><th>平均</th></tr></thead>
|
||||
<tbody>
|
||||
{% for e in summary.score_scale %}
|
||||
<tr>
|
||||
<td>{{ e.exam }}</td><td>{{ e.n }}</td>
|
||||
<td>{{ e.min }}</td><td>{{ e.max }}</td><td>{{ e.mean }}</td>
|
||||
</tr>
|
||||
<tr><td>{{ e.exam }}</td><td>{{ e.n }}</td><td>{{ e.min }}</td><td>{{ e.max }}</td><td>{{ e.mean }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="tiny">平均分从高一到高三明显下降是正常的(试卷结构不同),所以不能直接比原始分,只能比同次考试里的相对位置。</p>
|
||||
<p class="tiny">有分人数不等于测算人数:未排除的异动学生若缺考,仍会按 0 分进入排名。</p>
|
||||
</div>
|
||||
</div>
|
||||
<h3>各班人数</h3>
|
||||
<div class="chips">
|
||||
{% for k,v in summary.classes.items() %}<span class="chip">{{ k }}班 {{ v }}人</span>{% endfor %}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a class="primary" href="/analysis/{{ item.id }}?step=2">口径看起来对,下一步</a>
|
||||
</div>
|
||||
<div class="actions"><a class="primary" href="/analysis/{{ item.id }}?step=2">下一步设置</a></div>
|
||||
{% endif %}
|
||||
|
||||
{% if step == 2 %}
|
||||
<h1>第二步:口径与目标线</h1>
|
||||
<p class="muted">下达指标前先把“算谁、瞄哪条线”钉死。默认排除艺体和长期请假,明远生可参与年级排名。</p>
|
||||
<form method="post" action="/analysis/{{ item.id }}/settings" onsubmit="if(document.activeElement && document.activeElement.value==='compute'){this.querySelector('[value=compute]').textContent='正在滤波测算…'; this.querySelector('[value=compute]').disabled=false;}">
|
||||
<h3>用于滤波的考试</h3>
|
||||
<p class="tiny">建议全选。系统会自动让近考权重大、早考权重小;不是把五次考试简单平均。</p>
|
||||
<h1>第二步:口径、层次与线</h1>
|
||||
<form method="post" action="/analysis/{{ item.id }}/settings">
|
||||
<h3>考试范围</h3>
|
||||
<p class="tiny">考试列只来自本次上传工作簿的物理/历史人员表;只比较这里勾选后的相邻两次考试。例如勾选 7 次,会生成 6 组转移样本;最后一次既进入相邻概率计算,也用于最终摊派。</p>
|
||||
<div class="explain" style="margin-bottom:10px"><b>本次源表</b>:{{ item.filename }};方向人员表识别到 {{ summary.exams|length }} 个考试列。</div>
|
||||
{% for e in summary.exams %}
|
||||
<label class="check"><input type="checkbox" name="exams" value="{{ e }}" {% if e in settings.exams %}checked{% endif %}> {{ e }}(有效 {{ summary.exam_present[e] }} 人)</label>
|
||||
<label class="check"><input type="checkbox" name="exams" value="{{ e }}" {% if e in settings.exams %}checked{% endif %}> {{ e }}(有分 {{ summary.exam_present[e] }} 人)</label>
|
||||
{% endfor %}
|
||||
<div class="grid-2" style="margin-top:12px">
|
||||
|
||||
<div class="grid-2" style="margin-top:16px">
|
||||
<div>
|
||||
<h3>纳入测算</h3>
|
||||
<h3>纳入范围</h3>
|
||||
<label class="check"><input type="checkbox" name="exclude_moves" {% if settings.exclude_moves %}checked{% endif %}> 排除异动学生</label>
|
||||
<p class="tiny">勾选后,「异动/备注」列非空的学生整行不参与排名和概率计算;不勾选则纳入,且缺考按 0 分排名。</p>
|
||||
<label class="check"><input type="checkbox" name="include_mingyuan" {% if settings.include_mingyuan %}checked{% endif %}> 包含明远 / 港籍学籍</label>
|
||||
<label class="check"><input type="checkbox" name="include_other" {% if settings.include_other %}checked{% endif %}> 包含其他学籍</label>
|
||||
<label class="check"><input type="checkbox" name="include_art" {% if settings.include_art %}checked{% endif %}> 包含艺体生</label>
|
||||
<label class="check"><input type="checkbox" name="include_leave" {% if settings.include_leave %}checked{% endif %}> 包含请假 / 出国 / 休学备注</label>
|
||||
{% if preview_n is not none %}<p class="tiny">按当前口径约 {{ preview_n }} 人进入测算。</p>{% endif %}
|
||||
{% if preview_n is not none %}<p class="tiny">当前口径进入测算 {{ preview_n }} 人。</p>{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<h3>预估多远</h3>
|
||||
<label>预测步长</label>
|
||||
<select name="horizon">
|
||||
<option value="1" {% if settings.horizon == 1 %}selected{% endif %}>下一次大考(1 步)</option>
|
||||
<option value="2" {% if settings.horizon == 2 %}selected{% endif %}>大约高考前再考 2 次的距离</option>
|
||||
</select>
|
||||
<p class="tiny">步长越远,不确定性越大,临界生会变多,下达人数仍按期望取整到目标总量。</p>
|
||||
<h3>计算规则</h3>
|
||||
<p class="explain">
|
||||
同分数为同名次,后续名次递增。层次边界遇到同分时整组留在上一层;
|
||||
下一层从下一个不同名次开始,不会把边界同分拆到两组。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<h3>各方向目标人数</h3>
|
||||
<p class="tiny">这是年级总盘子。班级指标由个人达线概率加总后再取整,合计等于这里的人数。</p>
|
||||
|
||||
<h3>各方向线与层次宽度</h3>
|
||||
<p class="tiny">线按“方向内名次”设置,可在下方直接修改。历史方向默认按 basedata.xlsx 使用 5 人一层;物理默认 20 人一层。</p>
|
||||
{% for g in settings.groups %}
|
||||
<div class="explain" style="margin-bottom:12px">
|
||||
<b>{{ g }}</b>
|
||||
{% for t in settings.targets[g] %}
|
||||
<div class="grid-2" style="margin-top:8px">
|
||||
<input type="hidden" name="t_id_{{ g }}" value="{{ t.id }}">
|
||||
<div><label>指标名称</label><input name="t_name_{{ g }}" value="{{ t.name }}"></div>
|
||||
<div><label>年级总人数</label><input name="t_k_{{ g }}" type="number" min="1" value="{{ t.k }}"></div>
|
||||
<b>{{ g }}方向</b>
|
||||
<div class="grid-3" style="margin-top:8px">
|
||||
<div><label>尖子线人数</label><input name="top_k_{{ g }}" type="number" min="1" value="{{ settings.line_settings[g].top }}"></div>
|
||||
<div><label>特控线人数</label><input name="control_k_{{ g }}" type="number" min="1" value="{{ settings.line_settings[g].control }}"></div>
|
||||
<div><label>每层人数</label><input name="layer_size_{{ g }}" type="number" min="1" value="{{ settings.line_settings[g].layer_size }}"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="actions">
|
||||
<button class="ghost" name="action" value="save">只保存口径</button>
|
||||
<button class="primary" name="action" value="compute">开始滤波测算</button>
|
||||
<button class="primary" name="action" value="compute">开始计算概率与摊派</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if step == 3 %}
|
||||
<h1>第三步:滤波在做什么</h1>
|
||||
<h1>第三步:相邻命中概率与拟合</h1>
|
||||
{% if not result %}
|
||||
<p class="muted">还没有测算结果。请回到第二步点击「开始滤波测算」。</p>
|
||||
<a class="primary" href="/analysis/{{ item.id }}?step=2">去设置口径</a>
|
||||
<p class="muted">还没有测算结果。请回到第二步点击测算。</p>
|
||||
<a class="primary" href="/analysis/{{ item.id }}?step=2">去设置</a>
|
||||
{% elif result.get('algorithm') != 'adjacent-layer-markov-v2' %}
|
||||
<div class="warn">这是旧算法结果。请回到第二步重新测算一次。</div>
|
||||
{% else %}
|
||||
<div class="route">
|
||||
<i>原始分</i><i class="alt">同次标准化</i><i>卡尔曼估计水平</i><i class="alt">模拟下一次排名</i><i>个人概率</i><i class="alt">班级加总取整</i>
|
||||
</div>
|
||||
<p class="explain">
|
||||
把每次考试看成一次带噪声的定位:发挥好坏是测量噪声 R,真实进步是过程噪声 Q。
|
||||
R 相对大时,系统不会只信最近一次;Q 相对大时,会更快跟上近期趋势。这就是导航里“既看 GPS,也看车自己开的方向”。
|
||||
</p>
|
||||
{% for g in result.groups %}
|
||||
<h3>{{ g.group }} · 模型参数</h3>
|
||||
<div class="kpis">
|
||||
<div class="kpi"><em>过程噪声 Q(真实水平会变)</em><strong>{{ g.model.q }}</strong></div>
|
||||
<div class="kpi"><em>测量噪声 R(发挥波动)</em><strong>{{ g.model.r }}</strong></div>
|
||||
<div class="kpi"><em>一步预测 RMSE</em><strong>{{ g.model.rmse }}</strong></div>
|
||||
<div class="kpi"><em>测算人数 / 模拟次数</em><strong>{{ g.n }} / {{ g.model.n_sim }}</strong></div>
|
||||
<div class="kpi"><em>进入测算</em><strong>{{ result.n_selected }} 人</strong></div>
|
||||
<div class="kpi"><em>缺考处理</em><strong>0 分排名</strong></div>
|
||||
<div class="kpi"><em>异动排除</em><strong>{{ '是' if result.exclude_moves else '否' }}</strong></div>
|
||||
<div class="kpi"><em>相邻对数</em><strong>{{ result.pair_count }}</strong></div>
|
||||
</div>
|
||||
{% if g.holdout %}
|
||||
<p class="tiny">留出最近一次考试做对照:用更早的成绩预估最近一次,看滤波是否比“只看上一场名次”更准。</p>
|
||||
{% for g in result.groups %}
|
||||
<div class="explain" style="margin-top:18px">
|
||||
<b>{{ g.group }}方向</b>:{{ g.n }} 人,{{ g.layer_size }} 名一层,共 {{ g.n_layers }} 层;
|
||||
尖子线前 {{ g.line_settings.top }} 名,特控线前 {{ g.line_settings.control }} 名。
|
||||
</div>
|
||||
|
||||
<h3>汇总概率与拟合概率</h3>
|
||||
<p class="tiny">拟合规则与 basedata.xlsx 保持一致:第 1 层保留合成概率;中间层用加权 logistic 拟合;首个命中为 0 的层及其后取 0。最后一次考试也已纳入相邻转移计算。</p>
|
||||
<div class="grid-2">
|
||||
{% for line_id in ['top', 'control'] %}
|
||||
{% set sm = g.summary[line_id] %}
|
||||
<div>
|
||||
<h4>{{ '尖子线' if line_id == 'top' else '特控线' }}</h4>
|
||||
<table class="table">
|
||||
<thead><tr><th>目标</th><th>K</th><th>滤波命中</th><th>只看最近一次</th></tr></thead>
|
||||
<thead><tr><th>前一次层次</th><th>样本 n</th><th>命中 k</th><th>合成概率</th><th>拟合概率</th></tr></thead>
|
||||
<tbody>
|
||||
{% for h in g.holdout %}
|
||||
{% for x in sm.layers %}
|
||||
<tr>
|
||||
<td>{{ h.target_name }}</td>
|
||||
<td>{{ h.k }}</td>
|
||||
<td>{{ h.kf_hit }} / {{ h.k }}({{ '%.0f'|format(h.kf_precision * 100) }}%)</td>
|
||||
<td>{{ h.last_hit }} / {{ h.k }}({{ '%.0f'|format(h.last_precision * 100) }}%)</td>
|
||||
<td>{{ x.label }}</td><td>{{ x.previous_n }}</td><td>{{ x.hit_n }}</td>
|
||||
<td>{{ '%.2f'|format(x.probability * 100) if x.probability is not none else '—' }}</td>
|
||||
<td><b>{{ '%.2f'|format(x.fitted_probability * 100) }}</b></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<div class="actions">
|
||||
<a class="primary" href="/analysis/{{ item.id }}?step=4">看各班下达指标</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<h3>每次相邻比较明细</h3>
|
||||
{% for line in g.pairs %}
|
||||
{% for pair in line.pairs %}
|
||||
<details>
|
||||
<summary><b>{{ line.line }}</b>:{{ pair.from_exam }} → {{ pair.to_exam }}</summary>
|
||||
<div style="overflow:auto">
|
||||
<table class="table">
|
||||
<thead><tr><th>前一次层次</th><th>人数</th><th>下次到线</th><th>概率</th></tr></thead>
|
||||
<tbody>
|
||||
{% for x in pair.layers %}
|
||||
<tr><td>{{ x.label or x.layer }}</td><td>{{ x.previous_n }}</td><td>{{ x.hit_n }}</td>
|
||||
<td>{{ '%.2f'|format(x.probability * 100) if x.probability is not none else '—' }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
<div class="actions"><a class="primary" href="/analysis/{{ item.id }}?step=4">看指标摊派</a></div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if step == 4 %}
|
||||
<h1>第四步:下达指标</h1>
|
||||
{% if not result %}
|
||||
<p class="muted">还没有测算结果。请先完成第二步测算。</p>
|
||||
<h1>第四步:最后一次成绩摊派</h1>
|
||||
{% if not result or result.get('algorithm') != 'adjacent-layer-markov-v2' %}
|
||||
<p class="muted">还没有新算法结果。请回第二步重新测算。</p>
|
||||
<a class="primary" href="/analysis/{{ item.id }}?step=2">去设置</a>
|
||||
{% else %}
|
||||
<p class="explain">
|
||||
「下达指标」是滤波期望取整后的人数,含义是:若学生按现有轨迹走,这个班本来应当到线的人数。
|
||||
班会目标是超越它,而不是刚好打平。保底 / 冲刺是模拟分布的大约 20% / 80% 分位,用来看把握而不是另下两套考核。
|
||||
</p>
|
||||
<p class="explain">按最后一次考试的各班层次人数,乘以汇总后的拟合概率;加权计算四舍五入为摊派指标。点击班级可查看学生最后一次名次。</p>
|
||||
{% for g in result.groups %}
|
||||
{% for t in g.targets %}
|
||||
<h3>{{ g.group }} · {{ t.name }}(年级 {{ t.k }} 人)</h3>
|
||||
<p class="tiny">期望合计 {{ t.sum_expected }},临界生(概率 20%–80%){{ t.critical_n }} 人。点击班级看学生概率,点击学生看轨迹。</p>
|
||||
<h3>{{ g.group }}方向 · {{ t.name }}(前 {{ t.k }} 名)</h3>
|
||||
<p class="tiny">年级最后一次在线 {{ t.sum_current }} 人;加权合计 {{ t.sum_expected }};摊派合计 {{ t.sum_assigned }} 人。</p>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>班级</th><th>测算人数</th><th>最近在线</th><th>滤波期望</th>
|
||||
<th>下达指标</th><th>保底</th><th>冲刺</th><th>相对最近</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<thead><tr><th>班级</th><th>测算人数</th><th>最后在线</th><th>加权计算</th><th>摊派指标</th><th>相对最后在线</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in t.classes %}
|
||||
<tr class="clickable" onclick="openClass({{ item.id }}, '{{ g.group }}', '{{ t.id }}', '{{ row.class_name }}')">
|
||||
<td>{{ row.class_name }}班</td>
|
||||
<td>{{ row.n }}</td>
|
||||
<td>{{ row.current }}</td>
|
||||
<td>{{ '%.2f'|format(row.expected) }}</td>
|
||||
<td><b>{{ row.assigned }}</b></td>
|
||||
<td>{{ row.floor }}</td>
|
||||
<td>{{ row.stretch }}</td>
|
||||
<td class="{% if row.delta > 0 %}delta-up{% elif row.delta < 0 %}delta-down{% endif %}">
|
||||
{% if row.delta > 0 %}+{{ row.delta }}{% else %}{{ row.delta }}{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="8" class="tiny">{{ class_comment(row, t.name) }}</td>
|
||||
<td>{{ row.class_name }}班</td><td>{{ row.n }}</td><td>{{ row.current }}</td>
|
||||
<td>{{ '%.2f'|format(row.expected) }}</td><td><b>{{ row.assigned }}</b></td>
|
||||
<td class="{% if row.delta > 0 %}delta-up{% elif row.delta < 0 %}delta-down{% endif %}">{% if row.delta > 0 %}+{{ row.delta }}{% else %}{{ row.delta }}{% endif %}</td>
|
||||
</tr>
|
||||
<tr><td colspan="6" class="tiny">{{ class_comment(row, t.name) }}</td></tr>
|
||||
{% endfor %}
|
||||
<tr><th>合计</th><th>{{ g.n }}</th><th>{{ t.sum_current }}</th><th>{{ t.sum_expected }}</th><th>{{ t.sum_assigned }}</th><th>—</th></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% if t.critical %}
|
||||
<h4>临界生(优先关注)</h4>
|
||||
|
||||
<details>
|
||||
<summary>查看班级层次分布</summary>
|
||||
<div style="overflow:auto">
|
||||
<table class="table">
|
||||
<thead><tr><th>姓名</th><th>班</th><th>最近名次</th><th>达线概率</th></tr></thead>
|
||||
<thead><tr><th>班级</th>{% for label in t.labels %}<th>{{ label }}</th>{% endfor %}</tr></thead>
|
||||
<tbody>
|
||||
{% for s in t.critical[:15] %}
|
||||
<tr class="clickable" onclick="openStudent({{ item.id }}, '{{ s.sid }}')">
|
||||
<td>{{ s.name }}</td><td>{{ s.class_name }}</td>
|
||||
<td>{{ s.last_rank or "—" }}</td>
|
||||
<td><span class="tag mid">{{ '%.1f'|format(s.p * 100) }}%</span></td>
|
||||
</tr>
|
||||
{% for row in t.classes %}
|
||||
<tr><td>{{ row.class_name }}班</td>{% for c in row.layer_counts %}<td>{{ c }}</td>{% endfor %}</tr>
|
||||
{% endfor %}
|
||||
<tr><td><b>拟合概率</b></td>{% for p in t.fitted %}<td>{{ '%.2f'|format(p * 100) }}</td>{% endfor %}</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
<div class="actions">
|
||||
<a class="primary" href="/analysis/{{ item.id }}/export">导出下达表</a>
|
||||
<a class="ghost" href="/analysis/{{ item.id }}?step=2">调整口径再算</a>
|
||||
<a class="primary" href="/analysis/{{ item.id }}/export">导出下载总表</a>
|
||||
<a class="ghost" href="/analysis/{{ item.id }}?step=2">调整线再算</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="drawer-bg" id="drawer">
|
||||
<div class="drawer" id="drawer-body"></div>
|
||||
</div>
|
||||
<script>
|
||||
window.HB_EXAMS = {{ (result.exams if result else summary.exams) | tojson }};
|
||||
</script>
|
||||
<div class="drawer-bg" id="drawer"><div class="drawer" id="drawer-body"></div></div>
|
||||
<script>window.HB_EXAMS = {{ (result.exams if result else summary.exams) | tojson }};</script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,38 +3,38 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>航标 · 班级达线指标预估</title>
|
||||
<title>指标测算 · 相邻层次法</title>
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/">
|
||||
<div class="mark">航</div>
|
||||
<div><b>航标</b><span>班级达线指标预估系统</span></div>
|
||||
<div class="mark">层</div>
|
||||
<div><b>指标测算</b><span>相邻层次指标测算</span></div>
|
||||
</a>
|
||||
<form action="/logout" method="post"><button class="ghost" type="submit">退出 {{ user }}</button></form>
|
||||
</header>
|
||||
<main class="wrap">
|
||||
<section class="hero">
|
||||
<div>
|
||||
<h1>把历史成绩当成导航轨迹,<br>而不是一张静态排名表。</h1>
|
||||
<h1>相邻两次考试做层次转移,<br>用拟合概率下达班级指标。</h1>
|
||||
<p>
|
||||
上传各次大考总分后,系统会把分数换成同一次考试内的标准分,用卡尔曼滤波估计每个学生的稳定水平,
|
||||
再模拟下一次全年级排名,把达线概率加总到班级。下达的是“按现有轨迹本来会到的人数”,班级工作是超越它。
|
||||
系统按方向内名次分层次,统计每次相邻考试中各层次的到线人数;汇总后计算拟合概率,
|
||||
最后按最后一次考试的班级层次分布摊派尖子线和特控线指标。可勾选是否排除异动学生。
|
||||
</p>
|
||||
</div>
|
||||
<form class="drop" action="/upload" method="post" enctype="multipart/form-data">
|
||||
<h3>上传成绩表</h3>
|
||||
<p class="muted">xlsx,需含学号/姓名/班级,以及多次考试总分列。若有「方向」或物理/历史分表,会分开测算。</p>
|
||||
<p class="muted">xlsx,需含学号/姓名/班级,以及多次考试总分列。物理/历史分表会分开测算。</p>
|
||||
<input type="file" name="file" accept=".xlsx,.xlsm" required>
|
||||
<div class="actions">
|
||||
<button class="primary" type="submit">开始逐步分析</button>
|
||||
<button class="primary" type="submit">开始逐步测算</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% if has_sample %}
|
||||
<form action="/demo" method="post" style="margin-top:12px">
|
||||
<button class="btn teal" type="submit">用文件夹里的 2024 级示例表走一遍</button>
|
||||
<button class="btn teal" type="submit">用文件夹里的 basedata.xlsx(物理方向人员/历史方向人员)走一遍</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<div class="list">
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>登录 · 航标</title>
|
||||
<title>登录 · 指标测算</title>
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-wrap">
|
||||
<form class="login-card" method="post" action="/login">
|
||||
<div class="mark">航</div>
|
||||
<h1>航标</h1>
|
||||
<p class="muted">用历史成绩滤波,预估各班后续达线指标。管理员登录后上传成绩表,逐步确认口径,再下达可超越的班级目标。</p>
|
||||
<h1>指标测算</h1>
|
||||
<p class="muted">用相邻考试层次转移和拟合概率,测算各班尖子线、特控线指标。管理员登录后上传成绩表,逐步确认口径。</p>
|
||||
<label>账号</label>
|
||||
<input name="username" value="admin" autocomplete="username">
|
||||
<label>密码</label>
|
||||
|
||||
BIN
basedata.xlsx
Normal file
BIN
basedata.xlsx
Normal file
Binary file not shown.
Reference in New Issue
Block a user