兼容单科和总分
This commit is contained in:
Binary file not shown.
@@ -1,6 +1,6 @@
|
|||||||
# 指标测算 · 相邻层次法
|
# 指标测算 · 相邻层次法
|
||||||
|
|
||||||
B/S 系统:上传历次大考总分,按方向内名次把学生分到固定宽度层次;每次只比较相邻两次考试的“上一层次 → 本次到线”命中人数;汇总后做拟合概率,再用最后一次考试各班层次人数摊派尖子线和特控线指标。
|
B/S 系统:上传历次大考总分或单科分,按方向内名次把学生分到固定宽度层次;每次只比较相邻两次考试的“上一层次 → 本次到线”命中人数;汇总后做拟合概率,再用最后一次考试各班层次人数摊派尖子线和特控线指标。
|
||||||
|
|
||||||
## 启动
|
## 启动
|
||||||
|
|
||||||
@@ -18,12 +18,13 @@ python3 -m app
|
|||||||
## 成绩表要求
|
## 成绩表要求
|
||||||
|
|
||||||
- Excel `.xlsx`
|
- Excel `.xlsx`
|
||||||
- 至少包含:学号/姓名、班级、多次考试总分
|
- 至少包含:学号/姓名、班级、多次考试成绩列
|
||||||
|
- 成绩列可以是总分(如 `高一下期末-总分`),也可以是单科分(如 `高一下期末-数学分`、`高三分班合成-语文分`)
|
||||||
- 建议包含:方向、选科、学籍、异动
|
- 建议包含:方向、选科、学籍、异动
|
||||||
- 物理/历史分表可直接识别;同分按同名次,后续名次递增
|
- 物理/历史分表可直接识别;同分按同名次,后续名次递增
|
||||||
- 未排除的缺考学生按 0 分参加同方向排名
|
- 未排除的缺考学生按 0 分参加同方向排名
|
||||||
|
|
||||||
同目录 `basedata.xlsx` 上传后可作为按新算法复算的示例。
|
同目录 `basedata.xlsx` 是总分示例;`2024级高三指标数核算表:数学(六次).xlsx`、`2024级高三指标数核算表:语文(六次).xlsx` 可作为单科示例。
|
||||||
|
|
||||||
## Web 设置
|
## Web 设置
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
import re
|
import re
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from openpyxl import load_workbook
|
from openpyxl import load_workbook
|
||||||
|
from openpyxl.utils import column_index_from_string
|
||||||
|
|
||||||
|
|
||||||
META_ALIASES = {
|
META_ALIASES = {
|
||||||
@@ -18,9 +20,12 @@ META_ALIASES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
EXAM_HINT = re.compile(
|
EXAM_HINT = re.compile(
|
||||||
r"(总分|期末|联考|月考|模考|开学|学考|诊断|调研|高考)",
|
r"(总分|期中|期末|联考|月考|模考|开学|学考|诊断|调研|高考|分班合成)",
|
||||||
re.I,
|
re.I,
|
||||||
)
|
)
|
||||||
|
SUBJECT_SUFFIX = re.compile(
|
||||||
|
r"-(语文|数学|英语|外语|物理|化学|生物|历史|地理|政治|思想政治)分$"
|
||||||
|
)
|
||||||
SKIP_SHEETS = ("说明", "readme", "目录")
|
SKIP_SHEETS = ("说明", "readme", "目录")
|
||||||
|
|
||||||
|
|
||||||
@@ -32,6 +37,8 @@ def _is_skip_sheet(name: str) -> bool:
|
|||||||
|
|
||||||
ART_RE = re.compile(r"艺术|体育|艺体")
|
ART_RE = re.compile(r"艺术|体育|艺体")
|
||||||
LEAVE_RE = re.compile(r"请假|出国|休学|国际|不在校|港澳|港籍.*假")
|
LEAVE_RE = re.compile(r"请假|出国|休学|国际|不在校|港澳|港籍.*假")
|
||||||
|
CELL_REF_RE = re.compile(r"\$?([A-Za-z]+)\$?(\d+)")
|
||||||
|
MISSING_TOKENS = {"", "缺考", "—", "-", "/", "none", "nan"}
|
||||||
|
|
||||||
|
|
||||||
def _norm(v) -> str:
|
def _norm(v) -> str:
|
||||||
@@ -42,13 +49,97 @@ def _norm(v) -> str:
|
|||||||
return str(v).strip()
|
return str(v).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_arith(expr: str) -> float:
|
||||||
|
tree = ast.parse(expr, mode="eval")
|
||||||
|
allowed = (
|
||||||
|
ast.Expression,
|
||||||
|
ast.BinOp,
|
||||||
|
ast.UnaryOp,
|
||||||
|
ast.Add,
|
||||||
|
ast.Sub,
|
||||||
|
ast.Mult,
|
||||||
|
ast.Div,
|
||||||
|
ast.USub,
|
||||||
|
ast.UAdd,
|
||||||
|
ast.Constant,
|
||||||
|
ast.Load,
|
||||||
|
ast.Mod,
|
||||||
|
)
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not isinstance(node, allowed):
|
||||||
|
raise ValueError("unsupported formula")
|
||||||
|
return float(eval(compile(tree, "<score-formula>", "eval"), {"__builtins__": {}}, {}))
|
||||||
|
|
||||||
|
|
||||||
|
def _eval_simple_formula(formula: str, raw, row_number: int) -> float | None:
|
||||||
|
if not isinstance(formula, str) or not formula.startswith("="):
|
||||||
|
return None
|
||||||
|
expr = formula[1:].strip()
|
||||||
|
if re.search(r"[^0-9A-Za-z+\-*/().$,\s]", expr):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def repl(match: re.Match) -> str:
|
||||||
|
col = column_index_from_string(match.group(1).upper()) - 1
|
||||||
|
src_row = int(match.group(2))
|
||||||
|
if src_row != row_number or col < 0 or col >= len(raw):
|
||||||
|
raise ValueError("formula refers outside the current row")
|
||||||
|
val = raw[col]
|
||||||
|
if isinstance(val, str) and val.startswith("="):
|
||||||
|
raise ValueError("nested formula")
|
||||||
|
if val is None or str(val).strip().lower() in MISSING_TOKENS:
|
||||||
|
raise ValueError("missing ref")
|
||||||
|
return str(float(val))
|
||||||
|
|
||||||
|
try:
|
||||||
|
filled = CELL_REF_RE.sub(repl, expr)
|
||||||
|
return _safe_arith(filled)
|
||||||
|
except (TypeError, ValueError, SyntaxError, ZeroDivisionError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _to_score(val, raw, row_number: int):
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
if isinstance(val, str):
|
||||||
|
text = val.strip()
|
||||||
|
if text.lower() in MISSING_TOKENS:
|
||||||
|
return None
|
||||||
|
if text.startswith("="):
|
||||||
|
return _eval_simple_formula(text, raw, row_number)
|
||||||
|
val = text
|
||||||
|
try:
|
||||||
|
return float(val)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _is_exam_header(text: str) -> bool:
|
def _is_exam_header(text: str) -> bool:
|
||||||
t = text.strip()
|
t = text.strip()
|
||||||
if not t or t in {"考试次数"}:
|
if not t or t in {"考试次数"} or "次数" in t:
|
||||||
return False
|
return False
|
||||||
if t.endswith("-总分") or t.endswith("总分"):
|
# 总分、数学分、语文分、分班合成-数学分 等都视为成绩列。
|
||||||
|
if t.endswith("分"):
|
||||||
return True
|
return True
|
||||||
return bool(EXAM_HINT.search(t)) and "次数" not in t
|
return bool(EXAM_HINT.search(t))
|
||||||
|
|
||||||
|
|
||||||
|
def infer_score_kind(exam_names: list[str], source_name: str = "") -> str:
|
||||||
|
found: list[str] = []
|
||||||
|
for name in exam_names:
|
||||||
|
m = SUBJECT_SUFFIX.search(str(name).strip())
|
||||||
|
if m:
|
||||||
|
found.append(m.group(1))
|
||||||
|
elif str(name).endswith("总分"):
|
||||||
|
found.append("总分")
|
||||||
|
uniq = list(dict.fromkeys(found))
|
||||||
|
if len(uniq) == 1:
|
||||||
|
return uniq[0]
|
||||||
|
if uniq and "总分" not in uniq:
|
||||||
|
return uniq[0]
|
||||||
|
for subj in ("语文", "数学", "英语", "外语", "化学", "生物", "地理", "政治"):
|
||||||
|
if subj in source_name:
|
||||||
|
return subj
|
||||||
|
return "总分"
|
||||||
|
|
||||||
|
|
||||||
def _map_meta(header: str) -> str | None:
|
def _map_meta(header: str) -> str | None:
|
||||||
@@ -76,11 +167,12 @@ def _sheet_kind(name: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def parse_workbook(path: Path | BytesIO) -> dict:
|
def parse_workbook(path: Path | BytesIO) -> dict:
|
||||||
wb = load_workbook(path, data_only=True, read_only=True)
|
wb = load_workbook(path, data_only=False, read_only=True)
|
||||||
sheets = []
|
sheets = []
|
||||||
students: list[dict] = []
|
students: list[dict] = []
|
||||||
exam_order: list[str] = []
|
exam_order: list[str] = []
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
|
source_name = path.name if isinstance(path, Path) else ""
|
||||||
|
|
||||||
for ws in wb.worksheets:
|
for ws in wb.worksheets:
|
||||||
kind = _sheet_kind(ws.title)
|
kind = _sheet_kind(ws.title)
|
||||||
@@ -112,7 +204,7 @@ def parse_workbook(path: Path | BytesIO) -> dict:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
count = 0
|
count = 0
|
||||||
for raw in rows:
|
for row_number, raw in enumerate(rows, start=2):
|
||||||
if raw is None or all(v is None or str(v).strip() == "" for v in raw):
|
if raw is None or all(v is None or str(v).strip() == "" for v in raw):
|
||||||
continue
|
continue
|
||||||
rec = {
|
rec = {
|
||||||
@@ -143,14 +235,7 @@ def parse_workbook(path: Path | BytesIO) -> dict:
|
|||||||
for exam, idx in exam_idx.items():
|
for exam, idx in exam_idx.items():
|
||||||
if idx >= len(raw):
|
if idx >= len(raw):
|
||||||
continue
|
continue
|
||||||
val = raw[idx]
|
rec["scores"][exam] = _to_score(raw[idx], raw, row_number)
|
||||||
if val is None or str(val).strip() in ("", "缺考", "—", "-", "/"):
|
|
||||||
rec["scores"][exam] = None
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
rec["scores"][exam] = float(val)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
rec["scores"][exam] = None
|
|
||||||
rec["art"] = bool(ART_RE.search(rec["move"]) or ART_RE.search(ws.title))
|
rec["art"] = bool(ART_RE.search(rec["move"]) or ART_RE.search(ws.title))
|
||||||
rec["leave"] = bool(LEAVE_RE.search(rec["move"]))
|
rec["leave"] = bool(LEAVE_RE.search(rec["move"]))
|
||||||
students.append(rec)
|
students.append(rec)
|
||||||
@@ -195,12 +280,19 @@ def parse_workbook(path: Path | BytesIO) -> dict:
|
|||||||
warnings.append(f"按学号去重 {dups} 条")
|
warnings.append(f"按学号去重 {dups} 条")
|
||||||
|
|
||||||
if not exam_order:
|
if not exam_order:
|
||||||
warnings.append("未识别到考试成绩列,请确认表头含「总分」或「期末/联考/月考」")
|
warnings.append("未识别到考试成绩列,请确认表头含「总分」或单科分(如「高一下期末-数学分」)")
|
||||||
|
else:
|
||||||
|
for exam in exam_order:
|
||||||
|
present = sum(1 for s in students if s["scores"].get(exam) is not None)
|
||||||
|
if present == 0:
|
||||||
|
warnings.append(f"考试列「{exam}」没有读到数值,若是未计算的公式请先在 Excel 中打开保存后再上传")
|
||||||
|
|
||||||
|
score_kind = infer_score_kind(exam_order, source_name)
|
||||||
return {
|
return {
|
||||||
"sheets": sheets,
|
"sheets": sheets,
|
||||||
"students": students,
|
"students": students,
|
||||||
"exams": exam_order,
|
"exams": exam_order,
|
||||||
|
"score_kind": score_kind,
|
||||||
"warnings": warnings,
|
"warnings": warnings,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,6 +345,7 @@ def summarize_parse(parsed: dict) -> dict:
|
|||||||
"score_scale": score_scale,
|
"score_scale": score_scale,
|
||||||
"sheets": parsed["sheets"],
|
"sheets": parsed["sheets"],
|
||||||
"exams": exams,
|
"exams": exams,
|
||||||
|
"score_kind": parsed.get("score_kind") or infer_score_kind(exams),
|
||||||
"warnings": parsed["warnings"],
|
"warnings": parsed["warnings"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,16 @@ def _safe_title(text: str) -> str:
|
|||||||
return text.replace(":", "-").replace("/", "-").replace("\\", "-").replace("?", "-")[:31]
|
return text.replace(":", "-").replace("/", "-").replace("\\", "-").replace("?", "-")[:31]
|
||||||
|
|
||||||
|
|
||||||
|
def _score_kind(result_or_group: dict) -> str:
|
||||||
|
return (result_or_group.get("score_kind") or "总分").strip() or "总分"
|
||||||
|
|
||||||
|
|
||||||
|
def _line_title(group: dict, line_name: str) -> str:
|
||||||
|
kind = _score_kind(group)
|
||||||
|
prefix = "" if kind == "总分" else f"{kind}——"
|
||||||
|
return f"{prefix}{group['group']}方向{line_name}指标分配"
|
||||||
|
|
||||||
|
|
||||||
def _raw_sheet(wb: Workbook, group: dict, students: list[dict]):
|
def _raw_sheet(wb: Workbook, group: dict, students: list[dict]):
|
||||||
title = _safe_title(f"{group['group']}方向人员")
|
title = _safe_title(f"{group['group']}方向人员")
|
||||||
ws = wb.create_sheet(title)
|
ws = wb.create_sheet(title)
|
||||||
@@ -149,7 +159,7 @@ def _metric_sheet(wb: Workbook, group: dict):
|
|||||||
ws = wb.create_sheet(title)
|
ws = wb.create_sheet(title)
|
||||||
r = 1
|
r = 1
|
||||||
for line in group["targets"]:
|
for line in group["targets"]:
|
||||||
ws.cell(r, 1, f"{group['group']}方向{line['name']}指标分配")
|
ws.cell(r, 1, _line_title(group, line["name"]))
|
||||||
r += 1
|
r += 1
|
||||||
headers = ["班级"] + [f"{i+1}层人数" for i in range(group["n_layers"])] + ["加权计算", "摊派指标"]
|
headers = ["班级"] + [f"{i+1}层人数" for i in range(group["n_layers"])] + ["加权计算", "摊派指标"]
|
||||||
ws.cell(r, 1, headers[0])
|
ws.cell(r, 1, headers[0])
|
||||||
@@ -182,7 +192,11 @@ def _metric_sheet(wb: Workbook, group: dict):
|
|||||||
ws.cell(r, len(headers) - 1, line["sum_expected"])
|
ws.cell(r, len(headers) - 1, line["sum_expected"])
|
||||||
ws.cell(r, len(headers), line["sum_assigned"])
|
ws.cell(r, len(headers), line["sum_assigned"])
|
||||||
r += 1
|
r += 1
|
||||||
ws.cell(r, 1, "注:加权计算=Σ(各层人数×拟合概率);摊派指标=加权计算四舍五入。")
|
last_exam = (group.get("_exams") or [None])[-1]
|
||||||
|
note = "注:加权计算=Σ(各层人数×拟合概率);摊派指标=加权计算四舍五入。"
|
||||||
|
if last_exam:
|
||||||
|
note = f"注:数据源以最后一次考试「{last_exam}」为准。" + note[2:]
|
||||||
|
ws.cell(r, 1, note)
|
||||||
r += 3
|
r += 3
|
||||||
ws.column_dimensions["A"].width = 14
|
ws.column_dimensions["A"].width = 14
|
||||||
for c in range(2, ws.max_column + 1):
|
for c in range(2, ws.max_column + 1):
|
||||||
@@ -197,9 +211,11 @@ def build_export(result: dict) -> bytes:
|
|||||||
wb.remove(wb.active)
|
wb.remove(wb.active)
|
||||||
# Keep the same familiar order as basedata.xlsx: 物理 raw/prob/metric, then 历史.
|
# Keep the same familiar order as basedata.xlsx: 物理 raw/prob/metric, then 历史.
|
||||||
groups = sorted(result["groups"], key=lambda g: _group_order(g["group"]))
|
groups = sorted(result["groups"], key=lambda g: _group_order(g["group"]))
|
||||||
|
score_kind = _score_kind(result)
|
||||||
for i, group in enumerate(groups):
|
for i, group in enumerate(groups):
|
||||||
students = [s for s in result["students"] if s["group"] == group["group"]]
|
students = [s for s in result["students"] if s["group"] == group["group"]]
|
||||||
group["_exams"] = result["exams"]
|
group["_exams"] = result["exams"]
|
||||||
|
group["score_kind"] = score_kind
|
||||||
_raw_sheet(wb, group, students)
|
_raw_sheet(wb, group, students)
|
||||||
for group in groups:
|
for group in groups:
|
||||||
_probability_sheet(wb, group)
|
_probability_sheet(wb, group)
|
||||||
|
|||||||
@@ -347,8 +347,10 @@ def analyze(parsed: dict, settings: dict) -> dict:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
score_kind = parsed.get("score_kind") or "总分"
|
||||||
return {
|
return {
|
||||||
"algorithm": "adjacent-layer-markov-v2",
|
"algorithm": "adjacent-layer-markov-v2",
|
||||||
|
"score_kind": score_kind,
|
||||||
"exams": exams,
|
"exams": exams,
|
||||||
"layer_size": LAYER_SIZE,
|
"layer_size": LAYER_SIZE,
|
||||||
"pair_count": len(probability_exams) - 1 if len(probability_exams) > 1 else 0,
|
"pair_count": len(probability_exams) - 1 if len(probability_exams) > 1 else 0,
|
||||||
|
|||||||
@@ -40,11 +40,12 @@
|
|||||||
<section class="card panel">
|
<section class="card panel">
|
||||||
{% if step == 1 %}
|
{% if step == 1 %}
|
||||||
<h1>第一步:成绩表体检</h1>
|
<h1>第一步:成绩表体检</h1>
|
||||||
<p class="muted">这里只检查源表识别结果。缺考学生在后续测算中会按 0 分参加同方向排名。</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.score_kind or '总分' }}</strong></div>
|
||||||
<div class="kpi"><em>异动备注</em><strong>{{ summary.moves }}</strong></div>
|
<div class="kpi"><em>异动备注</em><strong>{{ summary.moves }}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
{% for w in summary.warnings %}
|
{% for w in summary.warnings %}
|
||||||
@@ -82,7 +83,7 @@
|
|||||||
<h1>第二步:口径、层次与线</h1>
|
<h1>第二步:口径、层次与线</h1>
|
||||||
<form method="post" action="/analysis/{{ item.id }}/settings">
|
<form method="post" action="/analysis/{{ item.id }}/settings">
|
||||||
<h3>考试范围</h3>
|
<h3>考试范围</h3>
|
||||||
<p class="tiny">考试列只来自本次上传工作簿的物理/历史人员表;只比较这里勾选后的相邻两次考试。例如勾选 7 次,会生成 6 组转移样本;最后一次既进入相邻概率计算,也用于最终摊派。</p>
|
<p class="tiny">考试列只来自本次上传工作簿的物理/历史人员表,总分或单科分均可;只比较这里勾选后的相邻两次考试。例如勾选 7 次,会生成 6 组转移样本;最后一次既进入相邻概率计算,也用于最终摊派。</p>
|
||||||
<div class="explain" style="margin-bottom:10px"><b>本次源表</b>:{{ item.filename }};方向人员表识别到 {{ summary.exams|length }} 个考试列。</div>
|
<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>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user