使用梅校的算法

This commit is contained in:
2026-09-26 19:21:16 +08:00
parent d9ff32d2bb
commit eeae5f9d68
12 changed files with 846 additions and 351 deletions

View File

@@ -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 口径重新计算后的结果。

View File

@@ -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"

View File

@@ -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"],

View File

@@ -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
View 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}"

View File

@@ -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")

View File

@@ -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; } }

View File

@@ -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 => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[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");
} }

View File

@@ -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>

View File

@@ -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">

View File

@@ -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

Binary file not shown.