使用梅校的算法
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
|
python3 -m app
|
||||||
```
|
```
|
||||||
|
|
||||||
浏览器打开 http://服务器IP:8765 (本机也可用 http://127.0.0.1:8765)
|
浏览器打开 http://127.0.0.1:8765 。服务默认监听 `0.0.0.0:8765`,可用 `HOST`、`PORT` 覆盖。
|
||||||
服务默认监听 `0.0.0.0:8765`。可用环境变量覆盖:`HOST=127.0.0.1 PORT=8765 ./run.sh`
|
|
||||||
|
|
||||||
默认账号:`admin` / `hangbiao2026`(请在 `app/config.py` 修改)
|
默认账号:`admin` / `hangbiao2026`(可在 `app/config.py` 修改)。
|
||||||
|
|
||||||
## 成绩表要求
|
## 成绩表要求
|
||||||
|
|
||||||
- Excel `.xlsx`
|
- Excel `.xlsx`
|
||||||
- 至少包含:学号或姓名、班级、多次考试总分
|
- 至少包含:学号/姓名、班级、多次考试总分
|
||||||
- 建议包含:方向(物理/历史)、选科、学籍、异动
|
- 建议包含:方向、选科、学籍、异动
|
||||||
- 物理/历史分表、艺体表、异动表都可以;系统会识别并在第二步让你勾选口径
|
- 物理/历史分表可直接识别;同分按同名次,后续名次递增
|
||||||
|
- 未排除的缺考学生按 0 分参加同方向排名
|
||||||
|
|
||||||
同目录的 `2024级指标分析成绩基础数据_5次考试.xlsx` 可在首页一键演示。
|
同目录 `basedata.xlsx` 上传后可作为按新算法复算的示例。
|
||||||
|
|
||||||
## 四步分析
|
## Web 设置
|
||||||
|
|
||||||
1. 数据体检:读到哪些人、哪些考试、分数尺度
|
- 勾选是否排除异动学生(异动/备注非空,或来源为异动/艺体表)
|
||||||
2. 口径与目标:算谁、年级总盘子、预估步长
|
- 修改每个方向的尖子线人数、特控线人数、每层人数
|
||||||
3. 滤波与回测:Q/R 含义,以及用历史考试验证准不准
|
- 选择参与计算哪些考试;只比较勾选后的相邻两次
|
||||||
4. 下达指标:班级期望 / 下达 / 保底 / 冲刺,点班级看学生,点学生看轨迹
|
- 第三步查看每次相邻命中概率、汇总概率和拟合概率;最后一次考试既参与相邻转移计算,也用于最终班级摊派
|
||||||
|
- 第四步查看班级摊派,并可导出总表
|
||||||
|
|
||||||
## 指标怎么理解
|
## 总表结构
|
||||||
|
|
||||||
- **下达指标**:滤波期望取整,合计等于年级目标人数
|
导出的 Excel 包含:
|
||||||
- **保底 / 冲刺**:模拟分布大约 20% / 80% 分位
|
|
||||||
- **相对最近一次**:预估相对最近一场已在线人数的增减,用来找上升生和不稳的在线生
|
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"
|
DATA_DIR = BASE_DIR / "data"
|
||||||
UPLOAD_DIR = DATA_DIR / "uploads"
|
UPLOAD_DIR = DATA_DIR / "uploads"
|
||||||
DB_PATH = DATA_DIR / "hangbiao.db"
|
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"
|
SECRET_KEY = "hangbiao-dev-secret-change-me"
|
||||||
ADMIN_USER = "admin"
|
ADMIN_USER = "admin"
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ EXAM_HINT = re.compile(
|
|||||||
re.I,
|
re.I,
|
||||||
)
|
)
|
||||||
SKIP_SHEETS = ("说明", "readme", "目录")
|
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"艺术|体育|艺体")
|
ART_RE = re.compile(r"艺术|体育|艺体")
|
||||||
LEAVE_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:
|
for ws in wb.worksheets:
|
||||||
kind = _sheet_kind(ws.title)
|
kind = _sheet_kind(ws.title)
|
||||||
|
if _is_skip_sheet(ws.title):
|
||||||
|
continue
|
||||||
rows = ws.iter_rows(values_only=True)
|
rows = ws.iter_rows(values_only=True)
|
||||||
try:
|
try:
|
||||||
header_row = next(rows)
|
header_row = next(rows)
|
||||||
@@ -201,7 +211,7 @@ def summarize_parse(parsed: dict) -> dict:
|
|||||||
tracks = {}
|
tracks = {}
|
||||||
classes = {}
|
classes = {}
|
||||||
status = {}
|
status = {}
|
||||||
art = leave = 0
|
art = leave = moves = 0
|
||||||
exam_present = {e: 0 for e in exams}
|
exam_present = {e: 0 for e in exams}
|
||||||
for s in students:
|
for s in students:
|
||||||
tracks[s["track"] or "未分方向"] = tracks.get(s["track"] or "未分方向", 0) + 1
|
tracks[s["track"] or "未分方向"] = tracks.get(s["track"] or "未分方向", 0) + 1
|
||||||
@@ -211,6 +221,8 @@ def summarize_parse(parsed: dict) -> dict:
|
|||||||
art += 1
|
art += 1
|
||||||
if s["leave"]:
|
if s["leave"]:
|
||||||
leave += 1
|
leave += 1
|
||||||
|
if (s.get("move") or "").strip() or s.get("sheet_kind") in ("art", "leave"):
|
||||||
|
moves += 1
|
||||||
for e in exams:
|
for e in exams:
|
||||||
if s["scores"].get(e) is not None:
|
if s["scores"].get(e) is not None:
|
||||||
exam_present[e] += 1
|
exam_present[e] += 1
|
||||||
@@ -236,6 +248,7 @@ def summarize_parse(parsed: dict) -> dict:
|
|||||||
"status": status,
|
"status": status,
|
||||||
"art": art,
|
"art": art,
|
||||||
"leave": leave,
|
"leave": leave,
|
||||||
|
"moves": moves,
|
||||||
"exam_present": exam_present,
|
"exam_present": exam_present,
|
||||||
"score_scale": score_scale,
|
"score_scale": score_scale,
|
||||||
"sheets": parsed["sheets"],
|
"sheets": parsed["sheets"],
|
||||||
|
|||||||
@@ -3,80 +3,208 @@ from __future__ import annotations
|
|||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
from openpyxl import Workbook
|
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:
|
def build_export(result: dict) -> bytes:
|
||||||
wb = Workbook()
|
wb = Workbook()
|
||||||
thin = Border(
|
wb.remove(wb.active)
|
||||||
left=Side(style="thin", color="D9D1C3"),
|
# Keep the same familiar order as basedata.xlsx: 物理 raw/prob/metric, then 历史.
|
||||||
right=Side(style="thin", color="D9D1C3"),
|
groups = sorted(result["groups"], key=lambda g: _group_order(g["group"]))
|
||||||
top=Side(style="thin", color="D9D1C3"),
|
for i, group in enumerate(groups):
|
||||||
bottom=Side(style="thin", color="D9D1C3"),
|
students = [s for s in result["students"] if s["group"] == group["group"]]
|
||||||
)
|
group["_exams"] = result["exams"]
|
||||||
head = PatternFill("solid", fgColor="12233D")
|
_raw_sheet(wb, group, students)
|
||||||
head_font = Font(color="F4EFE6", bold=True)
|
for group in groups:
|
||||||
wrap = Alignment(wrap_text=True, vertical="center")
|
_probability_sheet(wb, group)
|
||||||
|
for group in groups:
|
||||||
first = True
|
_metric_sheet(wb, group)
|
||||||
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)
|
|
||||||
|
|
||||||
bio = BytesIO()
|
bio = BytesIO()
|
||||||
wb.save(bio)
|
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}"
|
||||||
76
app/main.py
76
app/main.py
@@ -32,9 +32,9 @@ from .db import (
|
|||||||
)
|
)
|
||||||
from .excel_parse import parse_workbook, summarize_parse
|
from .excel_parse import parse_workbook, summarize_parse
|
||||||
from .export_xlsx import build_export
|
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.add_middleware(SessionMiddleware, secret_key=SECRET_KEY, max_age=60 * 60 * 12)
|
||||||
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
|
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
|
||||||
templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
|
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:
|
def _settings_from(parsed: dict, stored: dict | None) -> dict:
|
||||||
summary = summarize_parse(parsed)
|
summary = summarize_parse(parsed)
|
||||||
groups = sorted({infer_track_group(s["track"]) for s in parsed["students"]})
|
groups = sorted({infer_track_group(s["track"]) for s in parsed["students"]})
|
||||||
|
line_settings = default_line_settings(groups)
|
||||||
base = {
|
base = {
|
||||||
"exams": parsed["exams"],
|
"exams": parsed["exams"],
|
||||||
"include_art": False,
|
"exclude_moves": False,
|
||||||
"include_leave": False,
|
|
||||||
"include_mingyuan": True,
|
"include_mingyuan": True,
|
||||||
"include_other": True,
|
"include_other": True,
|
||||||
"horizon": 1,
|
"line_settings": line_settings,
|
||||||
"targets": default_targets(groups),
|
|
||||||
}
|
}
|
||||||
if stored:
|
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:
|
if k in stored:
|
||||||
base[k] = stored[k]
|
base[k] = stored[k]
|
||||||
base["summary"] = summary
|
base["summary"] = summary
|
||||||
@@ -154,6 +157,7 @@ def _settings_from(parsed: dict, stored: dict | None) -> dict:
|
|||||||
return base
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/analysis/{aid}", response_class=HTMLResponse)
|
@app.get("/analysis/{aid}", response_class=HTMLResponse)
|
||||||
def analysis_page(request: Request, aid: int, step: int = 1):
|
def analysis_page(request: Request, aid: int, step: int = 1):
|
||||||
if (redir := require_login(request)):
|
if (redir := require_login(request)):
|
||||||
@@ -189,34 +193,39 @@ async def save_settings(request: Request, aid: int):
|
|||||||
return HTMLResponse("分析不存在", status_code=404)
|
return HTMLResponse("分析不存在", status_code=404)
|
||||||
parsed = load_json(item["parsed_path"])
|
parsed = load_json(item["parsed_path"])
|
||||||
form = await request.form()
|
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:
|
if not exams:
|
||||||
exams = parsed["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"]})
|
groups = sorted({infer_track_group(s["track"]) for s in parsed["students"]})
|
||||||
targets = {}
|
defaults = default_line_settings(groups)
|
||||||
|
line_settings = {}
|
||||||
for g in groups:
|
for g in groups:
|
||||||
names = form.getlist(f"t_name_{g}")
|
try:
|
||||||
ks = form.getlist(f"t_k_{g}")
|
top = int(float(form.get(f"top_k_{g}", defaults[g]["top"])))
|
||||||
ids = form.getlist(f"t_id_{g}")
|
except ValueError:
|
||||||
rows = []
|
top = defaults[g]["top"]
|
||||||
for i, name in enumerate(names):
|
try:
|
||||||
k_raw = ks[i] if i < len(ks) else "0"
|
control = int(float(form.get(f"control_k_{g}", defaults[g]["control"])))
|
||||||
try:
|
except ValueError:
|
||||||
k = int(float(k_raw))
|
control = defaults[g]["control"]
|
||||||
except ValueError:
|
try:
|
||||||
k = 0
|
layer_size = int(float(form.get(f"layer_size_{g}", defaults[g]["layer_size"])))
|
||||||
tid = ids[i] if i < len(ids) and ids[i] else f"t{i+1}"
|
except ValueError:
|
||||||
if name.strip() and k > 0:
|
layer_size = defaults[g]["layer_size"]
|
||||||
rows.append({"id": tid, "name": name.strip(), "k": k})
|
line_settings[g] = {
|
||||||
targets[g] = rows or default_targets([g])[g]
|
"top": max(1, top),
|
||||||
|
"control": max(1, max(top, control)),
|
||||||
|
"layer_size": max(1, layer_size),
|
||||||
|
}
|
||||||
settings = {
|
settings = {
|
||||||
"exams": list(exams),
|
"exams": list(exams),
|
||||||
"include_art": form.get("include_art") == "on",
|
"exclude_moves": form.get("exclude_moves") == "on",
|
||||||
"include_leave": form.get("include_leave") == "on",
|
|
||||||
"include_mingyuan": form.get("include_mingyuan") == "on",
|
"include_mingyuan": form.get("include_mingyuan") == "on",
|
||||||
"include_other": form.get("include_other") == "on",
|
"include_other": form.get("include_other") == "on",
|
||||||
"horizon": int(form.get("horizon") or 1),
|
"line_settings": line_settings,
|
||||||
"targets": targets,
|
|
||||||
}
|
}
|
||||||
update_analysis(aid, settings_json=json.dumps(settings, ensure_ascii=False), step=2)
|
update_analysis(aid, settings_json=json.dumps(settings, ensure_ascii=False), step=2)
|
||||||
action = form.get("action") or "next"
|
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)
|
return RedirectResponse(f"/analysis/{aid}?step=2", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/analysis/{aid}/api/student/{sid}")
|
@app.get("/analysis/{aid}/api/student/{sid}")
|
||||||
def student_api(request: Request, aid: int, sid: str):
|
def student_api(request: Request, aid: int, sid: str):
|
||||||
if not current_user(request):
|
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"]
|
for s in result["students"]
|
||||||
if s["group"] == group and s["class_name"] == class_name
|
if s["group"] == group and s["class_name"] == class_name
|
||||||
]
|
]
|
||||||
rows.sort(key=lambda s: -((s.get("probs") or {}).get(target) or 0))
|
rows.sort(key=lambda s: (s.get("ranks") or {}).get(result["exams"][-1], 10**9))
|
||||||
return {"exams": result["exams"], "students": rows[:80], "n": len(rows)}
|
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")
|
@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"):
|
if not item or not item.get("result_path"):
|
||||||
return HTMLResponse("请先完成测算", status_code=400)
|
return HTMLResponse("请先完成测算", status_code=400)
|
||||||
result = load_json(item["result_path"])
|
result = load_json(item["result_path"])
|
||||||
|
if result.get("algorithm") != "adjacent-layer-markov-v2":
|
||||||
|
return HTMLResponse("结果为旧算法,请回到第二步重新测算后再导出。", status_code=400)
|
||||||
data = build_export(result)
|
data = build_export(result)
|
||||||
ascii_name = f"hangbiao-{aid}.xlsx"
|
ascii_name = f"hangbiao-{aid}.xlsx"
|
||||||
star = quote(f"{item['title']}-下达指标.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; }
|
.hero, .layout, .grid-2, .kpis { grid-template-columns: 1fr; }
|
||||||
.rail { position: static; }
|
.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");
|
if (el) el.classList.remove("show");
|
||||||
}
|
}
|
||||||
|
|
||||||
function sparkline(svg, exams, zs, xs, lastRank) {
|
function esc(value) {
|
||||||
const w = 520, h = 220, pad = 28;
|
return String(value ?? "").replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||||||
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>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openStudent(aid, sid) {
|
async function openStudent(aid, sid) {
|
||||||
const res = await fetch(`/analysis/${aid}/api/student/${encodeURIComponent(sid)}`);
|
const res = await fetch(`/analysis/${aid}/api/student/${encodeURIComponent(sid)}`);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const s = await res.json();
|
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 box = document.getElementById("drawer");
|
||||||
const body = document.getElementById("drawer-body");
|
const body = document.getElementById("drawer-body");
|
||||||
const probs = Object.entries(s.probs || {}).map(([k, v]) => `${k}: ${(v * 100).toFixed(1)}%`).join(" ");
|
|
||||||
body.innerHTML = `
|
body.innerHTML = `
|
||||||
<div style="display:flex;justify-content:space-between;align-items:start">
|
<div style="display:flex;justify-content:space-between;align-items:start">
|
||||||
<div>
|
<div>
|
||||||
<h2 style="margin:0">${s.name} <span class="tiny">${s.sid}</span></h2>
|
<h2 style="margin:0">${esc(s.name)} <span class="tiny">${esc(s.sid)}</span></h2>
|
||||||
<div class="tiny">${s.class_name}班 · ${s.group} · ${s.status || "在籍"} · ${s.subjects || ""}</div>
|
<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>
|
</div>
|
||||||
<button class="ghost" onclick="closeDrawer()">关闭</button>
|
<button class="ghost" onclick="closeDrawer()">关闭</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="explain" style="margin-top:14px">
|
<table class="table"><thead>
|
||||||
绿线是卡尔曼估计的稳定水平。如果橙点突然跳开绿线,系统会当成发挥波动,不会立刻改判断。
|
<tr><th>考试</th><th>原始分</th><th>方向名次</th><th>层次</th><th>计算分</th></tr>
|
||||||
达线概率 ${probs || "—"}。
|
</thead><tbody>${rows}</tbody></table>
|
||||||
</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>
|
|
||||||
`;
|
`;
|
||||||
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");
|
box.classList.add("show");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,27 +47,24 @@ async function openClass(aid, group, target, className) {
|
|||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const box = document.getElementById("drawer");
|
const lastExam = data.last_exam || (data.exams || []).slice(-1)[0];
|
||||||
const body = document.getElementById("drawer-body");
|
const rows = (data.students || []).map((s) => {
|
||||||
const rows = data.students.map((s) => {
|
const rank = (s.ranks || {})[lastExam];
|
||||||
const p = ((s.probs || {})[target] || 0) * 100;
|
const layer = (s.layers || {})[lastExam];
|
||||||
const tag = p >= 80 ? "high" : p >= 20 ? "mid" : "";
|
return `<tr class="clickable" onclick="openStudent(${aid}, '${esc(s.sid)}')">
|
||||||
return `<tr class="clickable" onclick="openStudent(${aid}, '${s.sid}')">
|
<td>${esc(s.name)}</td><td>${esc(s.status || "")}</td>
|
||||||
<td>${s.name}</td><td>${s.status || ""}</td>
|
<td>${rank ?? "—"}</td><td>${layer ?? "—"}</td>
|
||||||
<td>${(s.ranks || []).slice(-1)[0] ?? "—"}</td>
|
|
||||||
<td><span class="tag ${tag}">${p.toFixed(1)}%</span></td>
|
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
|
const box = document.getElementById("drawer");
|
||||||
|
const body = document.getElementById("drawer-body");
|
||||||
body.innerHTML = `
|
body.innerHTML = `
|
||||||
<div style="display:flex;justify-content:space-between">
|
<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>
|
<button class="ghost" onclick="closeDrawer()">关闭</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="tiny">点击学生可看成绩轨迹。共 ${data.n} 人,下列按达线概率排序。</p>
|
<p class="tiny">按最后一次方向名次排序,共 ${data.n} 人。</p>
|
||||||
<table class="table">
|
<table class="table"><thead><tr><th>姓名</th><th>学籍</th><th>名次</th><th>层次</th></tr></thead><tbody>${rows}</tbody></table>
|
||||||
<thead><tr><th>姓名</th><th>学籍</th><th>最近名次</th><th>达线概率</th></tr></thead>
|
|
||||||
<tbody>${rows}</tbody>
|
|
||||||
</table>
|
|
||||||
`;
|
`;
|
||||||
box.classList.add("show");
|
box.classList.add("show");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,30 +3,31 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<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">
|
<link rel="stylesheet" href="/static/css/app.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<a class="brand" href="/">
|
<a class="brand" href="/">
|
||||||
<div class="mark">航</div>
|
<div class="mark">层</div>
|
||||||
<div><b>航标</b><span>{{ item.title }}</span></div>
|
<div><b>指标测算</b><span>{{ item.title }}</span></div>
|
||||||
</a>
|
</a>
|
||||||
<div style="display:flex;gap:8px">
|
<div style="display:flex;gap:8px">
|
||||||
{% if result %}
|
{% if result and result.get('algorithm') == 'adjacent-layer-markov-v2' %}
|
||||||
<a class="ghost" href="/analysis/{{ item.id }}/export">导出 Excel</a>
|
<a class="ghost" href="/analysis/{{ item.id }}/export">导出总表</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form action="/logout" method="post"><button class="ghost" type="submit">退出</button></form>
|
<form action="/logout" method="post"><button class="ghost" type="submit">退出</button></form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="wrap layout">
|
<main class="wrap layout">
|
||||||
<aside class="rail">
|
<aside class="rail">
|
||||||
<h2>分析航线</h2>
|
<h2>测算航线</h2>
|
||||||
{% set steps = [
|
{% set steps = [
|
||||||
(1, '数据体检', '看清表格里有谁、有哪些考试'),
|
(1, '数据体检', '确认学生、考试、缺考'),
|
||||||
(2, '口径与目标', '决定算谁、瞄哪条线'),
|
(2, '口径与线', '排除异动、改尖子/特控线'),
|
||||||
(3, '滤波与回测', '理解预估为什么可信'),
|
(3, '相邻命中概率', '逐对考试汇总并拟合'),
|
||||||
(4, '下达指标', '班级目标、临界生、轨迹')
|
(4, '指标摊派', '最后一次成绩分班下达')
|
||||||
] %}
|
] %}
|
||||||
{% for n, title, desc in steps %}
|
{% for n, title, desc in steps %}
|
||||||
<a class="step {% if step == n %}on{% endif %}" href="/analysis/{{ item.id }}?step={{ n }}">
|
<a class="step {% if step == n %}on{% endif %}" href="/analysis/{{ item.id }}?step={{ n }}">
|
||||||
@@ -39,12 +40,12 @@
|
|||||||
<section class="card panel">
|
<section class="card panel">
|
||||||
{% if step == 1 %}
|
{% if step == 1 %}
|
||||||
<h1>第一步:成绩表体检</h1>
|
<h1>第一步:成绩表体检</h1>
|
||||||
<p class="muted">先确认系统读到的是你想算的那批人。分数量纲可以不同,系统稍后会在每次考试内部标准化。</p>
|
<p class="muted">这里只检查源表识别结果。缺考学生在后续测算中会按 0 分参加同方向排名。</p>
|
||||||
<div class="kpis">
|
<div class="kpis">
|
||||||
<div class="kpi"><em>识别学生</em><strong>{{ summary.n }}</strong></div>
|
<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.n_class }}</strong></div>
|
||||||
<div class="kpi"><em>考试次数</em><strong>{{ summary.exams|length }}</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>
|
</div>
|
||||||
{% for w in summary.warnings %}
|
{% for w in summary.warnings %}
|
||||||
<div class="warn">{{ w }}</div>
|
<div class="warn">{{ w }}</div>
|
||||||
@@ -53,211 +54,197 @@
|
|||||||
<div>
|
<div>
|
||||||
<h3>工作表</h3>
|
<h3>工作表</h3>
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead><tr><th>表名</th><th>类型</th><th>行数</th></tr></thead>
|
<thead><tr><th>名称</th><th>类型</th><th>行数</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for sh in summary.sheets %}
|
{% for s in summary.sheets %}
|
||||||
<tr><td>{{ sh.name }}</td><td>{{ sh.kind }}</td><td>{{ sh.rows }}</td></tr>
|
<tr><td>{{ s.name }}</td><td>{{ s.kind }}</td><td>{{ s.rows }}</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
||||||
<div>
|
<div>
|
||||||
<h3>考试识别与分数尺度</h3>
|
<h3>考试识别</h3>
|
||||||
<table class="table">
|
<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>
|
<tbody>
|
||||||
{% for e in summary.score_scale %}
|
{% for e in summary.score_scale %}
|
||||||
<tr>
|
<tr><td>{{ e.exam }}</td><td>{{ e.n }}</td><td>{{ e.min }}</td><td>{{ e.max }}</td><td>{{ e.mean }}</td></tr>
|
||||||
<td>{{ e.exam }}</td><td>{{ e.n }}</td>
|
|
||||||
<td>{{ e.min }}</td><td>{{ e.max }}</td><td>{{ e.mean }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<p class="tiny">平均分从高一到高三明显下降是正常的(试卷结构不同),所以不能直接比原始分,只能比同次考试里的相对位置。</p>
|
<p class="tiny">有分人数不等于测算人数:未排除的异动学生若缺考,仍会按 0 分进入排名。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3>各班人数</h3>
|
<div class="actions"><a class="primary" href="/analysis/{{ item.id }}?step=2">下一步设置</a></div>
|
||||||
<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>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if step == 2 %}
|
{% if step == 2 %}
|
||||||
<h1>第二步:口径与目标线</h1>
|
<h1>第二步:口径、层次与线</h1>
|
||||||
<p class="muted">下达指标前先把“算谁、瞄哪条线”钉死。默认排除艺体和长期请假,明远生可参与年级排名。</p>
|
<form method="post" action="/analysis/{{ item.id }}/settings">
|
||||||
<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>
|
||||||
<h3>用于滤波的考试</h3>
|
<p class="tiny">考试列只来自本次上传工作簿的物理/历史人员表;只比较这里勾选后的相邻两次考试。例如勾选 7 次,会生成 6 组转移样本;最后一次既进入相邻概率计算,也用于最终摊派。</p>
|
||||||
<p class="tiny">建议全选。系统会自动让近考权重大、早考权重小;不是把五次考试简单平均。</p>
|
<div class="explain" style="margin-bottom:10px"><b>本次源表</b>:{{ item.filename }};方向人员表识别到 {{ summary.exams|length }} 个考试列。</div>
|
||||||
{% for e in summary.exams %}
|
{% 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 %}
|
{% endfor %}
|
||||||
<div class="grid-2" style="margin-top:12px">
|
|
||||||
|
<div class="grid-2" style="margin-top:16px">
|
||||||
<div>
|
<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_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_other" {% if settings.include_other %}checked{% endif %}> 包含其他学籍</label>
|
||||||
<label class="check"><input type="checkbox" name="include_art" {% if settings.include_art %}checked{% endif %}> 包含艺体生</label>
|
{% if preview_n is not none %}<p class="tiny">当前口径进入测算 {{ preview_n }} 人。</p>{% endif %}
|
||||||
<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 %}
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3>预估多远</h3>
|
<h3>计算规则</h3>
|
||||||
<label>预测步长</label>
|
<p class="explain">
|
||||||
<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>
|
</p>
|
||||||
</select>
|
|
||||||
<p class="tiny">步长越远,不确定性越大,临界生会变多,下达人数仍按期望取整到目标总量。</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3>各方向目标人数</h3>
|
|
||||||
<p class="tiny">这是年级总盘子。班级指标由个人达线概率加总后再取整,合计等于这里的人数。</p>
|
<h3>各方向线与层次宽度</h3>
|
||||||
|
<p class="tiny">线按“方向内名次”设置,可在下方直接修改。历史方向默认按 basedata.xlsx 使用 5 人一层;物理默认 20 人一层。</p>
|
||||||
{% for g in settings.groups %}
|
{% for g in settings.groups %}
|
||||||
<div class="explain" style="margin-bottom:12px">
|
<div class="explain" style="margin-bottom:12px">
|
||||||
<b>{{ g }}</b>
|
<b>{{ g }}方向</b>
|
||||||
{% for t in settings.targets[g] %}
|
<div class="grid-3" style="margin-top:8px">
|
||||||
<div class="grid-2" style="margin-top:8px">
|
<div><label>尖子线人数</label><input name="top_k_{{ g }}" type="number" min="1" value="{{ settings.line_settings[g].top }}"></div>
|
||||||
<input type="hidden" name="t_id_{{ g }}" value="{{ t.id }}">
|
<div><label>特控线人数</label><input name="control_k_{{ g }}" type="number" min="1" value="{{ settings.line_settings[g].control }}"></div>
|
||||||
<div><label>指标名称</label><input name="t_name_{{ g }}" value="{{ t.name }}"></div>
|
<div><label>每层人数</label><input name="layer_size_{{ g }}" type="number" min="1" value="{{ settings.line_settings[g].layer_size }}"></div>
|
||||||
<div><label>年级总人数</label><input name="t_k_{{ g }}" type="number" min="1" value="{{ t.k }}"></div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="ghost" name="action" value="save">只保存口径</button>
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if step == 3 %}
|
{% if step == 3 %}
|
||||||
<h1>第三步:滤波在做什么</h1>
|
<h1>第三步:相邻命中概率与拟合</h1>
|
||||||
{% if not result %}
|
{% if not result %}
|
||||||
<p class="muted">还没有测算结果。请回到第二步点击「开始滤波测算」。</p>
|
<p class="muted">还没有测算结果。请回到第二步点击测算。</p>
|
||||||
<a class="primary" href="/analysis/{{ item.id }}?step=2">去设置口径</a>
|
<a class="primary" href="/analysis/{{ item.id }}?step=2">去设置</a>
|
||||||
|
{% elif result.get('algorithm') != 'adjacent-layer-markov-v2' %}
|
||||||
|
<div class="warn">这是旧算法结果。请回到第二步重新测算一次。</div>
|
||||||
{% else %}
|
{% 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="kpis">
|
||||||
<div class="kpi"><em>过程噪声 Q(真实水平会变)</em><strong>{{ g.model.q }}</strong></div>
|
<div class="kpi"><em>进入测算</em><strong>{{ result.n_selected }} 人</strong></div>
|
||||||
<div class="kpi"><em>测量噪声 R(发挥波动)</em><strong>{{ g.model.r }}</strong></div>
|
<div class="kpi"><em>缺考处理</em><strong>0 分排名</strong></div>
|
||||||
<div class="kpi"><em>一步预测 RMSE</em><strong>{{ g.model.rmse }}</strong></div>
|
<div class="kpi"><em>异动排除</em><strong>{{ '是' if result.exclude_moves else '否' }}</strong></div>
|
||||||
<div class="kpi"><em>测算人数 / 模拟次数</em><strong>{{ g.n }} / {{ g.model.n_sim }}</strong></div>
|
<div class="kpi"><em>相邻对数</em><strong>{{ result.pair_count }}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
{% if g.holdout %}
|
{% for g in result.groups %}
|
||||||
<p class="tiny">留出最近一次考试做对照:用更早的成绩预估最近一次,看滤波是否比“只看上一场名次”更准。</p>
|
<div class="explain" style="margin-top:18px">
|
||||||
<table class="table">
|
<b>{{ g.group }}方向</b>:{{ g.n }} 人,{{ g.layer_size }} 名一层,共 {{ g.n_layers }} 层;
|
||||||
<thead><tr><th>目标</th><th>K</th><th>滤波命中</th><th>只看最近一次</th></tr></thead>
|
尖子线前 {{ g.line_settings.top }} 名,特控线前 {{ g.line_settings.control }} 名。
|
||||||
<tbody>
|
</div>
|
||||||
{% for h in g.holdout %}
|
|
||||||
<tr>
|
<h3>汇总概率与拟合概率</h3>
|
||||||
<td>{{ h.target_name }}</td>
|
<p class="tiny">拟合规则与 basedata.xlsx 保持一致:第 1 层保留合成概率;中间层用加权 logistic 拟合;首个命中为 0 的层及其后取 0。最后一次考试也已纳入相邻转移计算。</p>
|
||||||
<td>{{ h.k }}</td>
|
<div class="grid-2">
|
||||||
<td>{{ h.kf_hit }} / {{ h.k }}({{ '%.0f'|format(h.kf_precision * 100) }}%)</td>
|
{% for line_id in ['top', 'control'] %}
|
||||||
<td>{{ h.last_hit }} / {{ h.k }}({{ '%.0f'|format(h.last_precision * 100) }}%)</td>
|
{% set sm = g.summary[line_id] %}
|
||||||
</tr>
|
<div>
|
||||||
|
<h4>{{ '尖子线' if line_id == 'top' else '特控线' }}</h4>
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr><th>前一次层次</th><th>样本 n</th><th>命中 k</th><th>合成概率</th><th>拟合概率</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for x in sm.layers %}
|
||||||
|
<tr>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
<div class="actions">
|
|
||||||
<a class="primary" href="/analysis/{{ item.id }}?step=4">看各班下达指标</a>
|
|
||||||
</div>
|
</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 %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if step == 4 %}
|
{% if step == 4 %}
|
||||||
<h1>第四步:下达指标</h1>
|
<h1>第四步:最后一次成绩摊派</h1>
|
||||||
{% if not result %}
|
{% if not result or result.get('algorithm') != 'adjacent-layer-markov-v2' %}
|
||||||
<p class="muted">还没有测算结果。请先完成第二步测算。</p>
|
<p class="muted">还没有新算法结果。请回第二步重新测算。</p>
|
||||||
|
<a class="primary" href="/analysis/{{ item.id }}?step=2">去设置</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="explain">
|
<p class="explain">按最后一次考试的各班层次人数,乘以汇总后的拟合概率;加权计算四舍五入为摊派指标。点击班级可查看学生最后一次名次。</p>
|
||||||
「下达指标」是滤波期望取整后的人数,含义是:若学生按现有轨迹走,这个班本来应当到线的人数。
|
|
||||||
班会目标是超越它,而不是刚好打平。保底 / 冲刺是模拟分布的大约 20% / 80% 分位,用来看把握而不是另下两套考核。
|
|
||||||
</p>
|
|
||||||
{% for g in result.groups %}
|
{% for g in result.groups %}
|
||||||
{% for t in g.targets %}
|
{% for t in g.targets %}
|
||||||
<h3>{{ g.group }} · {{ t.name }}(年级 {{ t.k }} 人)</h3>
|
<h3>{{ g.group }}方向 · {{ t.name }}(前 {{ t.k }} 名)</h3>
|
||||||
<p class="tiny">期望合计 {{ t.sum_expected }},临界生(概率 20%–80%){{ t.critical_n }} 人。点击班级看学生概率,点击学生看轨迹。</p>
|
<p class="tiny">年级最后一次在线 {{ t.sum_current }} 人;加权合计 {{ t.sum_expected }};摊派合计 {{ t.sum_assigned }} 人。</p>
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead><tr><th>班级</th><th>测算人数</th><th>最后在线</th><th>加权计算</th><th>摊派指标</th><th>相对最后在线</th></tr></thead>
|
||||||
<tr>
|
|
||||||
<th>班级</th><th>测算人数</th><th>最近在线</th><th>滤波期望</th>
|
|
||||||
<th>下达指标</th><th>保底</th><th>冲刺</th><th>相对最近</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for row in t.classes %}
|
{% for row in t.classes %}
|
||||||
<tr class="clickable" onclick="openClass({{ item.id }}, '{{ g.group }}', '{{ t.id }}', '{{ row.class_name }}')">
|
<tr class="clickable" onclick="openClass({{ item.id }}, '{{ g.group }}', '{{ t.id }}', '{{ row.class_name }}')">
|
||||||
<td>{{ row.class_name }}班</td>
|
<td>{{ row.class_name }}班</td><td>{{ row.n }}</td><td>{{ row.current }}</td>
|
||||||
<td>{{ row.n }}</td>
|
<td>{{ '%.2f'|format(row.expected) }}</td><td><b>{{ row.assigned }}</b></td>
|
||||||
<td>{{ row.current }}</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>
|
||||||
<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>
|
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr><td colspan="6" class="tiny">{{ class_comment(row, t.name) }}</td></tr>
|
||||||
{% endfor %}
|
{% 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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% if t.critical %}
|
|
||||||
<h4>临界生(优先关注)</h4>
|
<details>
|
||||||
<table class="table">
|
<summary>查看班级层次分布</summary>
|
||||||
<thead><tr><th>姓名</th><th>班</th><th>最近名次</th><th>达线概率</th></tr></thead>
|
<div style="overflow:auto">
|
||||||
<tbody>
|
<table class="table">
|
||||||
{% for s in t.critical[:15] %}
|
<thead><tr><th>班级</th>{% for label in t.labels %}<th>{{ label }}</th>{% endfor %}</tr></thead>
|
||||||
<tr class="clickable" onclick="openStudent({{ item.id }}, '{{ s.sid }}')">
|
<tbody>
|
||||||
<td>{{ s.name }}</td><td>{{ s.class_name }}</td>
|
{% for row in t.classes %}
|
||||||
<td>{{ s.last_rank or "—" }}</td>
|
<tr><td>{{ row.class_name }}班</td>{% for c in row.layer_counts %}<td>{{ c }}</td>{% endfor %}</tr>
|
||||||
<td><span class="tag mid">{{ '%.1f'|format(s.p * 100) }}%</span></td>
|
{% endfor %}
|
||||||
</tr>
|
<tr><td><b>拟合概率</b></td>{% for p in t.fitted %}<td>{{ '%.2f'|format(p * 100) }}</td>{% endfor %}</tr>
|
||||||
{% endfor %}
|
</tbody>
|
||||||
</tbody>
|
</table>
|
||||||
</table>
|
</div>
|
||||||
{% endif %}
|
</details>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a class="primary" href="/analysis/{{ item.id }}/export">导出下达表</a>
|
<a class="primary" href="/analysis/{{ item.id }}/export">导出下载总表</a>
|
||||||
<a class="ghost" href="/analysis/{{ item.id }}?step=2">调整口径再算</a>
|
<a class="ghost" href="/analysis/{{ item.id }}?step=2">调整线再算</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div class="drawer-bg" id="drawer">
|
<div class="drawer-bg" id="drawer"><div class="drawer" id="drawer-body"></div></div>
|
||||||
<div class="drawer" id="drawer-body"></div>
|
<script>window.HB_EXAMS = {{ (result.exams if result else summary.exams) | tojson }};</script>
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
window.HB_EXAMS = {{ (result.exams if result else summary.exams) | tojson }};
|
|
||||||
</script>
|
|
||||||
<script src="/static/js/app.js"></script>
|
<script src="/static/js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -3,38 +3,38 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>航标 · 班级达线指标预估</title>
|
<title>指标测算 · 相邻层次法</title>
|
||||||
<link rel="stylesheet" href="/static/css/app.css">
|
<link rel="stylesheet" href="/static/css/app.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<a class="brand" href="/">
|
<a class="brand" href="/">
|
||||||
<div class="mark">航</div>
|
<div class="mark">层</div>
|
||||||
<div><b>航标</b><span>班级达线指标预估系统</span></div>
|
<div><b>指标测算</b><span>相邻层次指标测算</span></div>
|
||||||
</a>
|
</a>
|
||||||
<form action="/logout" method="post"><button class="ghost" type="submit">退出 {{ user }}</button></form>
|
<form action="/logout" method="post"><button class="ghost" type="submit">退出 {{ user }}</button></form>
|
||||||
</header>
|
</header>
|
||||||
<main class="wrap">
|
<main class="wrap">
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div>
|
<div>
|
||||||
<h1>把历史成绩当成导航轨迹,<br>而不是一张静态排名表。</h1>
|
<h1>相邻两次考试做层次转移,<br>用拟合概率下达班级指标。</h1>
|
||||||
<p>
|
<p>
|
||||||
上传各次大考总分后,系统会把分数换成同一次考试内的标准分,用卡尔曼滤波估计每个学生的稳定水平,
|
系统按方向内名次分层次,统计每次相邻考试中各层次的到线人数;汇总后计算拟合概率,
|
||||||
再模拟下一次全年级排名,把达线概率加总到班级。下达的是“按现有轨迹本来会到的人数”,班级工作是超越它。
|
最后按最后一次考试的班级层次分布摊派尖子线和特控线指标。可勾选是否排除异动学生。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form class="drop" action="/upload" method="post" enctype="multipart/form-data">
|
<form class="drop" action="/upload" method="post" enctype="multipart/form-data">
|
||||||
<h3>上传成绩表</h3>
|
<h3>上传成绩表</h3>
|
||||||
<p class="muted">xlsx,需含学号/姓名/班级,以及多次考试总分列。若有「方向」或物理/历史分表,会分开测算。</p>
|
<p class="muted">xlsx,需含学号/姓名/班级,以及多次考试总分列。物理/历史分表会分开测算。</p>
|
||||||
<input type="file" name="file" accept=".xlsx,.xlsm" required>
|
<input type="file" name="file" accept=".xlsx,.xlsm" required>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="primary" type="submit">开始逐步分析</button>
|
<button class="primary" type="submit">开始逐步测算</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
{% if has_sample %}
|
{% if has_sample %}
|
||||||
<form action="/demo" method="post" style="margin-top:12px">
|
<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>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="list">
|
<div class="list">
|
||||||
|
|||||||
@@ -3,15 +3,15 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>登录 · 航标</title>
|
<title>登录 · 指标测算</title>
|
||||||
<link rel="stylesheet" href="/static/css/app.css">
|
<link rel="stylesheet" href="/static/css/app.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="login-wrap">
|
<div class="login-wrap">
|
||||||
<form class="login-card" method="post" action="/login">
|
<form class="login-card" method="post" action="/login">
|
||||||
<div class="mark">航</div>
|
<div class="mark">航</div>
|
||||||
<h1>航标</h1>
|
<h1>指标测算</h1>
|
||||||
<p class="muted">用历史成绩滤波,预估各班后续达线指标。管理员登录后上传成绩表,逐步确认口径,再下达可超越的班级目标。</p>
|
<p class="muted">用相邻考试层次转移和拟合概率,测算各班尖子线、特控线指标。管理员登录后上传成绩表,逐步确认口径。</p>
|
||||||
<label>账号</label>
|
<label>账号</label>
|
||||||
<input name="username" value="admin" autocomplete="username">
|
<input name="username" value="admin" autocomplete="username">
|
||||||
<label>密码</label>
|
<label>密码</label>
|
||||||
|
|||||||
BIN
basedata.xlsx
Normal file
BIN
basedata.xlsx
Normal file
Binary file not shown.
Reference in New Issue
Block a user