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