Initial commit
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
commit
71db82393a
@@ -0,0 +1 @@
|
||||
# era2 engines package
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,929 @@
|
||||
"""
|
||||
二期报告渲染引擎 (era2)
|
||||
基于一期 report_renderer.py 拷贝,改了 import 路径
|
||||
支持中英双语(lang="zh" / "en")
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
import numpy as np
|
||||
import sys
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
# era2 独立 config(不依赖 backend)
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from config_era2 import DIMENSION_FRAMEWORK, LEVEL_THRESHOLDS
|
||||
|
||||
# 项目根(用于添加 i18n 包到 sys.path)
|
||||
PROJECT_ROOT_FOR_I18N = Path(__file__).parent.parent.parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT_FOR_I18N))
|
||||
from i18n import get_translator, get_bundle # noqa: E402
|
||||
|
||||
# era2 模板目录(统一放在 report-admin/templates/era2)
|
||||
TEMPLATES_DIR = Path(__file__).parent.parent.parent.parent / "templates" / "era2"
|
||||
OUTPUT_DIR = Path(__file__).parent.parent.parent.parent / "output" / "era2"
|
||||
|
||||
# 二期暂无 SCHOOL_TYPE_MAP(176所学校没有逐校类型信息)
|
||||
SCHOOL_TYPE_MAP = {}
|
||||
|
||||
# 水平描述从 config 导入
|
||||
try:
|
||||
from config_era2 import LEVEL_DESCRIPTIONS
|
||||
except ImportError:
|
||||
LEVEL_DESCRIPTIONS = {}
|
||||
|
||||
|
||||
class ReportRendererEra2:
|
||||
"""二期 HTML 报告渲染引擎"""
|
||||
|
||||
def __init__(self):
|
||||
self.env = Environment(
|
||||
loader=FileSystemLoader(str(TEMPLATES_DIR)),
|
||||
autoescape=False,
|
||||
)
|
||||
|
||||
def render(self, report_data: Dict, llm_sections: Dict[str, str],
|
||||
enable_agent: bool = False, lang: str = "zh") -> str:
|
||||
"""
|
||||
渲染完整HTML报告
|
||||
|
||||
report_data: stats_engine.compute_school_report_data() 的输出
|
||||
llm_sections: llm_engine.generate_report_segments() 的输出
|
||||
enable_agent: 是否嵌入 AI 对话助手
|
||||
lang: 语言代码("zh" / "en")
|
||||
"""
|
||||
template = self.env.get_template("base.html")
|
||||
|
||||
# 翻译器
|
||||
t = get_translator(lang)
|
||||
|
||||
# 准备模板数据
|
||||
school = report_data["school"]
|
||||
district = report_data.get("district", "")
|
||||
|
||||
# 学校/区显示名(英文版区名翻译为 "Changning District" 等;学校名作为专有名词保留)
|
||||
school_display = school
|
||||
district_display = t.district(district) if district else district
|
||||
|
||||
# 用于模板/图表的两个翻译映射(中文 key → 当前 lang 显示)
|
||||
bundle = get_bundle(lang)
|
||||
dim_translation_map = bundle.get("DIMENSIONS", {})
|
||||
sub_translation_map = bundle.get("SUB_DIMENSIONS", {})
|
||||
|
||||
# ECharts JS 用 i18n bundle
|
||||
ec_i18n = dict(bundle.get("UI", {}))
|
||||
|
||||
# 子维度小节编号(中文:二、三..;英文:II. III..)
|
||||
if lang == "en":
|
||||
sub_section_labels = ["", "II.", "III.", "IV.", "V.", "VI.", "VII.", "VIII.", "IX."]
|
||||
ag_section_labels = ["I.", "II.", "III.", "IV.", "V.", "VI.", "VII.", "VIII.", "IX."]
|
||||
cn_subnums = sub_section_labels
|
||||
cn_nums = ag_section_labels
|
||||
html_lang = "en"
|
||||
else:
|
||||
sub_section_labels = ["", "二、", "三、", "四、", "五、", "六、", "七、", "八、", "九、"]
|
||||
ag_section_labels = ["一、", "二、", "三、", "四、", "五、", "六、", "七、", "八、", "九、"]
|
||||
cn_subnums = sub_section_labels
|
||||
cn_nums = ag_section_labels
|
||||
html_lang = "zh-CN"
|
||||
|
||||
# 生成日期(按语言)
|
||||
generation_date = t.date(datetime.now())
|
||||
|
||||
context = {
|
||||
"school": school,
|
||||
"school_display": school_display,
|
||||
"district": district,
|
||||
"district_display": district_display,
|
||||
"total_schools_in_district": report_data.get("total_schools_in_district", report_data["overall"].get("total_schools", 0)),
|
||||
"school_info": report_data["school_info"],
|
||||
"overall": self._to_namespace(report_data["overall"]),
|
||||
"dimensions": {
|
||||
name: self._to_namespace(data)
|
||||
for name, data in report_data["dimensions"].items()
|
||||
},
|
||||
"sub_dimensions": {
|
||||
name: self._to_namespace(data)
|
||||
for name, data in report_data["sub_dimensions"].items()
|
||||
},
|
||||
"framework": {
|
||||
name: self._to_namespace(info)
|
||||
for name, info in DIMENSION_FRAMEWORK.items()
|
||||
},
|
||||
"llm_sections": llm_sections,
|
||||
"level_descriptions": LEVEL_DESCRIPTIONS,
|
||||
"level_descriptions_keys": list(LEVEL_DESCRIPTIONS.keys()),
|
||||
"generation_date": generation_date,
|
||||
|
||||
# i18n 注入
|
||||
"t": t,
|
||||
"lang": lang,
|
||||
"html_lang": html_lang,
|
||||
"ec_i18n": ec_i18n,
|
||||
"dim_translation_map": dim_translation_map,
|
||||
"sub_translation_map": sub_translation_map,
|
||||
"cn_subnums": cn_subnums,
|
||||
"cn_nums": cn_nums,
|
||||
"ag_section_labels": ag_section_labels,
|
||||
|
||||
# ECharts数据 — 传原始dict,由模板的tojson过滤器序列化一次
|
||||
"radar_data": self._build_radar_data(report_data),
|
||||
"sub_dim_chart_data": self._build_sub_dim_charts(report_data),
|
||||
"score_compare_data": self._build_score_compare(report_data),
|
||||
"cluster_radar_data": self._build_cluster_radar(report_data),
|
||||
"correlation_data": self._build_correlation_heatmap(report_data),
|
||||
"level_dist_data": self._build_level_distribution(report_data),
|
||||
"school_ranking_data": self._build_school_ranking(report_data),
|
||||
# 新增图表数据
|
||||
"cluster_type_dist_data": self._build_cluster_type_distribution(report_data),
|
||||
"cluster_line_compare_data": self._build_cluster_line_compare(report_data),
|
||||
"dim_scatter_data": self._build_dim_scatter_charts(report_data),
|
||||
"dim_score_bar_data": self._build_dim_score_bars(report_data),
|
||||
"dim_sub_radar_data": self._build_dim_sub_radar_charts(report_data),
|
||||
# 创新图表
|
||||
"profile_card_data": self._build_profile_card(report_data),
|
||||
"quadrant_data": self._build_quadrant_chart(report_data),
|
||||
"thermometer_data": self._build_thermometer_data(report_data),
|
||||
"waterfall_data": self._build_waterfall_chart(report_data),
|
||||
}
|
||||
|
||||
# AI 对话助手(可选)
|
||||
if enable_agent:
|
||||
chat_config, report_json = self._build_chat_config(report_data)
|
||||
context["chat_config"] = chat_config
|
||||
context["report_data_json"] = report_json
|
||||
else:
|
||||
context["chat_config"] = None
|
||||
context["report_data_json"] = "{}"
|
||||
|
||||
return template.render(**context)
|
||||
|
||||
def render_to_file(self, report_data: Dict, llm_sections: Dict[str, str],
|
||||
output_path: Path = None, enable_agent: bool = False,
|
||||
lang: str = "zh") -> Path:
|
||||
"""渲染并保存到文件"""
|
||||
html = self.render(report_data, llm_sections, enable_agent=enable_agent, lang=lang)
|
||||
school = report_data["school"]
|
||||
|
||||
if output_path is None:
|
||||
output_dir = OUTPUT_DIR
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
suffix = "_report_en.html" if lang == "en" else "_报告.html"
|
||||
output_path = output_dir / f"{school}{suffix}"
|
||||
|
||||
output_path.write_text(html, encoding="utf-8")
|
||||
return output_path
|
||||
|
||||
def _build_radar_data(self, report_data: Dict) -> Dict:
|
||||
"""构建雷达图数据"""
|
||||
school = report_data["school"]
|
||||
dims = list(report_data["dimensions"].keys())
|
||||
school_values = [report_data["dimensions"][d]["score"] for d in dims]
|
||||
avg_values = [report_data["dimensions"][d]["district_avg"] for d in dims]
|
||||
|
||||
return {
|
||||
"dimensions": dims,
|
||||
"legend": [school, "区均值"],
|
||||
"series": [
|
||||
{"name": school, "values": school_values},
|
||||
{"name": "区均值", "values": avg_values},
|
||||
],
|
||||
}
|
||||
|
||||
def _build_sub_dim_charts(self, report_data: Dict) -> Dict:
|
||||
"""构建各维度的子维度柱状图数据"""
|
||||
charts = {}
|
||||
part_names = {
|
||||
"课程领导力": "part3", "教学变革力": "part4", "学生发展指导力": "part5",
|
||||
"教师发展支持力": "part6", "教育质量评估力": "part7",
|
||||
"教育条件保障力": "part8", "数字化赋能力": "part9",
|
||||
}
|
||||
|
||||
for dim_name, info in DIMENSION_FRAMEWORK.items():
|
||||
part_id = part_names[dim_name]
|
||||
sub_dims = info["sub_dimensions"]
|
||||
|
||||
categories = []
|
||||
school_values = []
|
||||
avg_values = []
|
||||
|
||||
for sd in sub_dims:
|
||||
sd_data = report_data["sub_dimensions"].get(sd, {})
|
||||
if sd_data:
|
||||
categories.append(sd)
|
||||
school_values.append(round(sd_data["score"], 2))
|
||||
avg_values.append(round(sd_data["district_avg"], 2))
|
||||
|
||||
charts[part_id] = {
|
||||
"categories": categories,
|
||||
"school_values": school_values,
|
||||
"avg_values": avg_values,
|
||||
}
|
||||
|
||||
return charts
|
||||
|
||||
def _build_score_compare(self, report_data: Dict) -> Dict:
|
||||
"""构建得分对比横向条形图数据:本校 vs 区均值 vs 同类学校均值"""
|
||||
school = report_data["school"]
|
||||
dims = list(report_data["dimensions"].keys())
|
||||
return {
|
||||
"categories": dims,
|
||||
"school_values": [round(report_data["dimensions"][d]["score"], 2) for d in dims],
|
||||
"district_avg": [round(report_data["dimensions"][d]["district_avg"], 2) for d in dims],
|
||||
"same_type_avg": [round(report_data["dimensions"][d]["same_type_avg"], 2) for d in dims],
|
||||
"school_name": school,
|
||||
}
|
||||
|
||||
def _build_cluster_radar(self, report_data: Dict) -> Dict:
|
||||
"""构建聚类类型特征对比雷达图(较好类 vs 待提升类)"""
|
||||
all_dim_scores = report_data.get("all_schools_dim_scores", {})
|
||||
if not all_dim_scores:
|
||||
return {}
|
||||
|
||||
# 从 overall cluster info 中提取各学校的聚类标签
|
||||
school = report_data["school"]
|
||||
dims = list(report_data["dimensions"].keys())
|
||||
|
||||
# 计算各学校的总体得分来判断聚类
|
||||
school_totals = {}
|
||||
for s in list(list(all_dim_scores.values())[0].keys()):
|
||||
total = 0
|
||||
for d in dims:
|
||||
total += all_dim_scores.get(d, {}).get(s, 50)
|
||||
school_totals[s] = total / len(dims)
|
||||
|
||||
# 二分聚类(简单按总分中位数分)
|
||||
median_score = sorted(school_totals.values())[len(school_totals) // 2]
|
||||
good_schools = [s for s, v in school_totals.items() if v >= median_score]
|
||||
weak_schools = [s for s, v in school_totals.items() if v < median_score]
|
||||
|
||||
good_avgs = []
|
||||
weak_avgs = []
|
||||
for d in dims:
|
||||
dim_data = all_dim_scores.get(d, {})
|
||||
good_avgs.append(round(sum(dim_data.get(s, 50) for s in good_schools) / max(len(good_schools), 1), 2))
|
||||
weak_avgs.append(round(sum(dim_data.get(s, 50) for s in weak_schools) / max(len(weak_schools), 1), 2))
|
||||
|
||||
return {
|
||||
"dimensions": dims,
|
||||
"legend": ["课程实施较好类", "课程实施待提升类", school],
|
||||
"series": [
|
||||
{"name": "课程实施较好类", "values": good_avgs},
|
||||
{"name": "课程实施待提升类", "values": weak_avgs},
|
||||
{"name": school, "values": [round(report_data["dimensions"][d]["score"], 2) for d in dims]},
|
||||
],
|
||||
"good_count": len(good_schools),
|
||||
"weak_count": len(weak_schools),
|
||||
}
|
||||
|
||||
def _build_correlation_heatmap(self, report_data: Dict) -> Dict:
|
||||
"""构建维度间相关性热力图"""
|
||||
correlation = report_data.get("correlation", {})
|
||||
if not correlation:
|
||||
return {}
|
||||
|
||||
dims = list(correlation.keys())
|
||||
# 构建二维数组 [x_index, y_index, value]
|
||||
data = []
|
||||
for i, d1 in enumerate(dims):
|
||||
for j, d2 in enumerate(dims):
|
||||
val = correlation.get(d1, {}).get(d2, 0)
|
||||
data.append([i, j, round(val, 3) if val is not None else 0])
|
||||
|
||||
# 短名
|
||||
short_names = [d.replace("力", "").replace("教育", "") for d in dims]
|
||||
|
||||
return {
|
||||
"dimensions": dims,
|
||||
"short_names": short_names,
|
||||
"data": data,
|
||||
}
|
||||
|
||||
def _build_level_distribution(self, report_data: Dict) -> Dict:
|
||||
"""构建各三级维度水平分布堆叠条形图"""
|
||||
charts = {}
|
||||
part_names = {
|
||||
"课程领导力": "part3", "教学变革力": "part4", "学生发展指导力": "part5",
|
||||
"教师发展支持力": "part6", "教育质量评估力": "part7",
|
||||
"教育条件保障力": "part8", "数字化赋能力": "part9",
|
||||
}
|
||||
|
||||
for dim_name, info in DIMENSION_FRAMEWORK.items():
|
||||
part_id = part_names[dim_name]
|
||||
sub_dims = info["sub_dimensions"]
|
||||
|
||||
categories = []
|
||||
level1_pcts = []
|
||||
level2_pcts = []
|
||||
level3_pcts = []
|
||||
level4_pcts = []
|
||||
school_levels = []
|
||||
|
||||
for sd in sub_dims:
|
||||
sd_data = report_data["sub_dimensions"].get(sd, {})
|
||||
if not sd_data:
|
||||
continue
|
||||
dist = sd_data.get("level_distribution", {})
|
||||
total = sum(dist.values())
|
||||
if total == 0:
|
||||
continue
|
||||
|
||||
categories.append(sd)
|
||||
level1_pcts.append(round(dist.get("水平1", 0) / total * 100, 1))
|
||||
level2_pcts.append(round(dist.get("水平2", 0) / total * 100, 1))
|
||||
level3_pcts.append(round(dist.get("水平3", 0) / total * 100, 1))
|
||||
level4_pcts.append(round(dist.get("水平4", 0) / total * 100, 1))
|
||||
school_levels.append(sd_data.get("level", 0))
|
||||
|
||||
charts[part_id] = {
|
||||
"categories": categories,
|
||||
"level1": level1_pcts,
|
||||
"level2": level2_pcts,
|
||||
"level3": level3_pcts,
|
||||
"level4": level4_pcts,
|
||||
"school_levels": school_levels,
|
||||
}
|
||||
|
||||
return charts
|
||||
|
||||
def _build_school_ranking(self, report_data: Dict) -> Dict:
|
||||
"""构建区内各校维度排名对比图"""
|
||||
school = report_data["school"]
|
||||
all_dim_scores = report_data.get("all_schools_dim_scores", {})
|
||||
dims = list(report_data["dimensions"].keys())
|
||||
|
||||
if not all_dim_scores:
|
||||
return {}
|
||||
|
||||
# 获取所有学校名
|
||||
first_dim = list(all_dim_scores.values())[0] if all_dim_scores else {}
|
||||
all_schools = list(first_dim.keys())
|
||||
|
||||
# 计算每校总体得分并排序
|
||||
school_totals = {}
|
||||
for s in all_schools:
|
||||
total = sum(all_dim_scores.get(d, {}).get(s, 50) for d in dims)
|
||||
school_totals[s] = round(total / len(dims), 2)
|
||||
|
||||
sorted_schools = sorted(school_totals.items(), key=lambda x: x[1], reverse=True)
|
||||
|
||||
return {
|
||||
"schools": [s[0] for s in sorted_schools],
|
||||
"total_scores": [s[1] for s in sorted_schools],
|
||||
"current_school": school,
|
||||
"dimensions": dims,
|
||||
"dim_scores": {
|
||||
d: [round(all_dim_scores.get(d, {}).get(s[0], 50), 2) for s in sorted_schools]
|
||||
for d in dims
|
||||
},
|
||||
}
|
||||
|
||||
def _build_cluster_type_distribution(self, report_data: Dict) -> Dict:
|
||||
"""
|
||||
构建课程实施类型分布饼/条形图数据(如参考报告图2-2)
|
||||
展示 较好类 vs 待提升类 在区内各校的分布
|
||||
"""
|
||||
all_dim_scores = report_data.get("all_schools_dim_scores", {})
|
||||
if not all_dim_scores:
|
||||
return {}
|
||||
|
||||
school = report_data["school"]
|
||||
dims = list(report_data["dimensions"].keys())
|
||||
|
||||
# 计算各学校总体得分并二分聚类
|
||||
school_totals = {}
|
||||
first_dim_data = list(all_dim_scores.values())[0] if all_dim_scores else {}
|
||||
all_schools = list(first_dim_data.keys())
|
||||
|
||||
for s in all_schools:
|
||||
total = sum(all_dim_scores.get(d, {}).get(s, 50) for d in dims)
|
||||
school_totals[s] = total / len(dims)
|
||||
|
||||
median_score = sorted(school_totals.values())[len(school_totals) // 2]
|
||||
good_schools = [s for s, v in school_totals.items() if v >= median_score]
|
||||
weak_schools = [s for s, v in school_totals.items() if v < median_score]
|
||||
|
||||
school_cluster = "较好" if school in good_schools else "待提升"
|
||||
|
||||
return {
|
||||
"good_count": len(good_schools),
|
||||
"weak_count": len(weak_schools),
|
||||
"total": len(all_schools),
|
||||
"school_cluster": school_cluster,
|
||||
"school_name": school,
|
||||
"good_schools": good_schools,
|
||||
"weak_schools": weak_schools,
|
||||
}
|
||||
|
||||
def _build_cluster_line_compare(self, report_data: Dict) -> Dict:
|
||||
"""
|
||||
构建两类学校特征折线对比图(如参考报告图2-3)
|
||||
两条折线:较好类 vs 待提升类在7个维度上的得分
|
||||
"""
|
||||
all_dim_scores = report_data.get("all_schools_dim_scores", {})
|
||||
if not all_dim_scores:
|
||||
return {}
|
||||
|
||||
school = report_data["school"]
|
||||
dims = list(report_data["dimensions"].keys())
|
||||
|
||||
# 计算聚类
|
||||
first_dim_data = list(all_dim_scores.values())[0] if all_dim_scores else {}
|
||||
all_schools = list(first_dim_data.keys())
|
||||
|
||||
school_totals = {}
|
||||
for s in all_schools:
|
||||
total = sum(all_dim_scores.get(d, {}).get(s, 50) for d in dims)
|
||||
school_totals[s] = total / len(dims)
|
||||
|
||||
median_score = sorted(school_totals.values())[len(school_totals) // 2]
|
||||
good_schools = [s for s, v in school_totals.items() if v >= median_score]
|
||||
weak_schools = [s for s, v in school_totals.items() if v < median_score]
|
||||
|
||||
good_avgs = []
|
||||
weak_avgs = []
|
||||
for d in dims:
|
||||
dim_data = all_dim_scores.get(d, {})
|
||||
good_avgs.append(round(sum(dim_data.get(s, 50) for s in good_schools) / max(len(good_schools), 1), 2))
|
||||
weak_avgs.append(round(sum(dim_data.get(s, 50) for s in weak_schools) / max(len(weak_schools), 1), 2))
|
||||
|
||||
return {
|
||||
"dimensions": dims,
|
||||
"good_values": good_avgs,
|
||||
"weak_values": weak_avgs,
|
||||
"good_count": len(good_schools),
|
||||
"weak_count": len(weak_schools),
|
||||
}
|
||||
|
||||
def _build_dim_scatter_charts(self, report_data: Dict) -> Dict:
|
||||
"""
|
||||
构建各二级维度的聚类散点图数据(2D / 3D)
|
||||
参考报告中每个维度都有一个散点图显示所有学校的聚类分布
|
||||
对于有2个子维度的 → 2D散点图
|
||||
对于有3个子维度的 → 3D散点图
|
||||
对于有4个子维度的 → 取前2个主成分的2D散点图
|
||||
"""
|
||||
all_sub_scores = report_data.get("all_schools_sub_scores", {})
|
||||
if not all_sub_scores:
|
||||
return {}
|
||||
|
||||
school = report_data["school"]
|
||||
charts = {}
|
||||
part_names = {
|
||||
"课程领导力": "part3", "教学变革力": "part4", "学生发展指导力": "part5",
|
||||
"教师发展支持力": "part6", "教育质量评估力": "part7",
|
||||
"教育条件保障力": "part8", "数字化赋能力": "part9",
|
||||
}
|
||||
|
||||
first_sub = list(all_sub_scores.values())[0] if all_sub_scores else {}
|
||||
all_schools = list(first_sub.keys())
|
||||
|
||||
for dim_name, info in DIMENSION_FRAMEWORK.items():
|
||||
part_id = part_names[dim_name]
|
||||
sub_dims = info["sub_dimensions"]
|
||||
n_subs = len(sub_dims)
|
||||
|
||||
# 获取各学校在这些子维度上的得分
|
||||
school_scores = {}
|
||||
for s in all_schools:
|
||||
scores = []
|
||||
for sd in sub_dims:
|
||||
val = all_sub_scores.get(sd, {}).get(s, 50)
|
||||
scores.append(round(float(val), 2))
|
||||
school_scores[s] = scores
|
||||
|
||||
# 简单二分聚类
|
||||
totals = {s: sum(v) / len(v) for s, v in school_scores.items()}
|
||||
med = sorted(totals.values())[len(totals) // 2]
|
||||
clusters = {s: 0 if totals[s] >= med else 1 for s in all_schools}
|
||||
|
||||
if n_subs == 2:
|
||||
# 2D散点图
|
||||
data_good = []
|
||||
data_weak = []
|
||||
school_point = None
|
||||
|
||||
for s in all_schools:
|
||||
point = school_scores[s]
|
||||
if s == school:
|
||||
school_point = point
|
||||
elif clusters[s] == 0:
|
||||
data_good.append(point)
|
||||
else:
|
||||
data_weak.append(point)
|
||||
|
||||
charts[part_id] = {
|
||||
"type": "2d",
|
||||
"axes": sub_dims,
|
||||
"good_data": data_good,
|
||||
"weak_data": data_weak,
|
||||
"school_point": school_point,
|
||||
"school_name": school,
|
||||
"dim_name": dim_name,
|
||||
}
|
||||
|
||||
elif n_subs == 3:
|
||||
# 3D散点图
|
||||
data_good = []
|
||||
data_weak = []
|
||||
school_point = None
|
||||
|
||||
for s in all_schools:
|
||||
point = school_scores[s]
|
||||
if s == school:
|
||||
school_point = point
|
||||
elif clusters[s] == 0:
|
||||
data_good.append(point)
|
||||
else:
|
||||
data_weak.append(point)
|
||||
|
||||
charts[part_id] = {
|
||||
"type": "3d",
|
||||
"axes": sub_dims,
|
||||
"good_data": data_good,
|
||||
"weak_data": data_weak,
|
||||
"school_point": school_point,
|
||||
"school_name": school,
|
||||
"dim_name": dim_name,
|
||||
}
|
||||
|
||||
elif n_subs >= 4:
|
||||
# 取前两个子维度做2D散点
|
||||
axes = sub_dims[:2]
|
||||
data_good = []
|
||||
data_weak = []
|
||||
school_point = None
|
||||
|
||||
for s in all_schools:
|
||||
point = school_scores[s][:2]
|
||||
if s == school:
|
||||
school_point = point
|
||||
elif clusters[s] == 0:
|
||||
data_good.append(point)
|
||||
else:
|
||||
data_weak.append(point)
|
||||
|
||||
charts[part_id] = {
|
||||
"type": "2d",
|
||||
"axes": axes,
|
||||
"good_data": data_good,
|
||||
"weak_data": data_weak,
|
||||
"school_point": school_point,
|
||||
"school_name": school,
|
||||
"dim_name": dim_name,
|
||||
}
|
||||
|
||||
return charts
|
||||
|
||||
def _build_dim_score_bars(self, report_data: Dict) -> Dict:
|
||||
"""
|
||||
构建各维度独立得分柱状图数据(如参考报告图3-1、图4-1等)
|
||||
展示本校 vs 区均值 vs 同类学校均值 的对比
|
||||
"""
|
||||
school = report_data["school"]
|
||||
charts = {}
|
||||
part_names = {
|
||||
"课程领导力": "part3", "教学变革力": "part4", "学生发展指导力": "part5",
|
||||
"教师发展支持力": "part6", "教育质量评估力": "part7",
|
||||
"教育条件保障力": "part8", "数字化赋能力": "part9",
|
||||
}
|
||||
|
||||
for dim_name, dim_data in report_data["dimensions"].items():
|
||||
part_id = part_names.get(dim_name, "")
|
||||
if not part_id:
|
||||
continue
|
||||
|
||||
charts[part_id] = {
|
||||
"dim_name": dim_name,
|
||||
"school_name": school,
|
||||
"school_score": round(dim_data["score"], 2),
|
||||
"district_avg": round(dim_data["district_avg"], 2),
|
||||
"same_type_avg": round(dim_data["same_type_avg"], 2),
|
||||
}
|
||||
|
||||
return charts
|
||||
|
||||
def _build_dim_sub_radar_charts(self, report_data: Dict) -> Dict:
|
||||
"""
|
||||
构建各二级维度的子维度雷达图(如参考报告图3-2)
|
||||
多条线对比: 本校 vs 区均值 vs 同类学校均值
|
||||
"""
|
||||
school = report_data["school"]
|
||||
all_sub_scores = report_data.get("all_schools_sub_scores", {})
|
||||
charts = {}
|
||||
part_names = {
|
||||
"课程领导力": "part3", "教学变革力": "part4", "学生发展指导力": "part5",
|
||||
"教师发展支持力": "part6", "教育质量评估力": "part7",
|
||||
"教育条件保障力": "part8", "数字化赋能力": "part9",
|
||||
}
|
||||
|
||||
# Get same-type schools
|
||||
school_info = report_data.get("school_info", {})
|
||||
school_type = school_info.get("type", "")
|
||||
all_schools = list(list(all_sub_scores.values())[0].keys()) if all_sub_scores else []
|
||||
same_type_schools = [s for s in all_schools if SCHOOL_TYPE_MAP.get(s, {}).get("type") == school_type]
|
||||
|
||||
for dim_name, info in DIMENSION_FRAMEWORK.items():
|
||||
part_id = part_names.get(dim_name, "")
|
||||
if not part_id:
|
||||
continue
|
||||
|
||||
sub_dims = info["sub_dimensions"]
|
||||
school_values = []
|
||||
district_avg = []
|
||||
same_type_avg = []
|
||||
|
||||
for sd in sub_dims:
|
||||
sd_data = report_data["sub_dimensions"].get(sd, {})
|
||||
if sd_data:
|
||||
school_values.append(round(sd_data["score"], 2))
|
||||
district_avg.append(round(sd_data["district_avg"], 2))
|
||||
# 计算同类学校均值
|
||||
if same_type_schools and all_sub_scores:
|
||||
st_vals = [all_sub_scores.get(sd, {}).get(s, 50) for s in same_type_schools]
|
||||
same_type_avg.append(round(sum(float(v) for v in st_vals) / len(st_vals), 2))
|
||||
else:
|
||||
same_type_avg.append(district_avg[-1])
|
||||
|
||||
if len(sub_dims) >= 3:
|
||||
charts[part_id] = {
|
||||
"type": "radar",
|
||||
"sub_dims": sub_dims,
|
||||
"school_name": school,
|
||||
"school_values": school_values,
|
||||
"district_avg": district_avg,
|
||||
"same_type_avg": same_type_avg,
|
||||
}
|
||||
else:
|
||||
charts[part_id] = {
|
||||
"type": "bar",
|
||||
"sub_dims": sub_dims,
|
||||
"school_name": school,
|
||||
"school_values": school_values,
|
||||
"district_avg": district_avg,
|
||||
"same_type_avg": same_type_avg,
|
||||
}
|
||||
|
||||
return charts
|
||||
|
||||
# ========== 创新图表数据构建 ==========
|
||||
|
||||
def _build_profile_card(self, report_data: Dict) -> Dict:
|
||||
"""
|
||||
A. 学校画像卡(综合仪表盘)
|
||||
- 总体得分环形仪表盘
|
||||
- 7个维度的红绿灯状态(基于各维度下子维度的最低水平)
|
||||
- 20个三级维度的水平分布概览
|
||||
"""
|
||||
school = report_data["school"]
|
||||
overall = report_data["overall"]
|
||||
|
||||
# 维度红绿灯:每个二级维度取其子维度的最低水平作为"短板"指示
|
||||
dim_signals = []
|
||||
for dim_name, dim_data in report_data["dimensions"].items():
|
||||
sub_dims = DIMENSION_FRAMEWORK[dim_name]["sub_dimensions"]
|
||||
levels = []
|
||||
for sd in sub_dims:
|
||||
sd_data = report_data["sub_dimensions"].get(sd, {})
|
||||
if sd_data:
|
||||
levels.append(sd_data.get("level", 0))
|
||||
min_level = min(levels) if levels else 0
|
||||
avg_level = round(sum(levels) / len(levels), 1) if levels else 0
|
||||
dim_signals.append({
|
||||
"name": dim_name,
|
||||
"score": round(dim_data["score"], 2),
|
||||
"min_level": min_level,
|
||||
"avg_level": avg_level,
|
||||
"rank": dim_data.get("rank_in_district", 0),
|
||||
})
|
||||
|
||||
# 20个三级维度的水平分布统计
|
||||
level_counts = {1: 0, 2: 0, 3: 0, 4: 0}
|
||||
for sd_name, sd_data in report_data["sub_dimensions"].items():
|
||||
lv = sd_data.get("level", 0)
|
||||
if lv in level_counts:
|
||||
level_counts[lv] += 1
|
||||
|
||||
return {
|
||||
"school_name": school,
|
||||
"school_type": report_data["school_info"].get("type", ""),
|
||||
"total_score": overall["score"],
|
||||
"district_avg": overall["district_avg"],
|
||||
"rank": overall["rank_in_district"],
|
||||
"total_schools": overall["total_schools"],
|
||||
"cluster": overall.get("cluster", ""),
|
||||
"dim_signals": dim_signals,
|
||||
"level_counts": level_counts,
|
||||
"total_sub_dims": sum(level_counts.values()),
|
||||
}
|
||||
|
||||
def _build_quadrant_chart(self, report_data: Dict) -> Dict:
|
||||
"""
|
||||
B. 优势-短板象限图(Gap Analysis)
|
||||
X轴 = 得分, Y轴 = 与区均值的差值
|
||||
四象限:右上=核心优势, 左下=急需改进, 右下=隐性风险, 左上=潜力项
|
||||
"""
|
||||
school = report_data["school"]
|
||||
items = []
|
||||
|
||||
for sd_name, sd_data in report_data["sub_dimensions"].items():
|
||||
parent_dim = sd_data.get("parent_dimension", "")
|
||||
items.append({
|
||||
"name": sd_name,
|
||||
"parent": parent_dim,
|
||||
"score": round(sd_data["score"], 2),
|
||||
"diff": round(sd_data["diff_district"], 2),
|
||||
"level": sd_data.get("level", 0),
|
||||
})
|
||||
|
||||
return {
|
||||
"school_name": school,
|
||||
"items": items,
|
||||
"x_center": 50, # 区均值(标准化后均值=50)
|
||||
"y_center": 0, # 差值=0 的参照线
|
||||
}
|
||||
|
||||
def _build_thermometer_data(self, report_data: Dict) -> Dict:
|
||||
"""
|
||||
C. 维度温度计条形图数据
|
||||
为每个三级维度构建横向温度计数据:
|
||||
- 得分范围20-80
|
||||
- 水平分界线
|
||||
- 本校位置、区均值位置、同类学校均值位置
|
||||
"""
|
||||
school = report_data["school"]
|
||||
school_type = report_data["school_info"].get("type", "")
|
||||
all_sub_scores = report_data.get("all_schools_sub_scores", {})
|
||||
same_type_schools = [s for s in SCHOOL_TYPE_MAP
|
||||
if SCHOOL_TYPE_MAP[s].get("type") == school_type]
|
||||
|
||||
thermometers = {}
|
||||
for sd_name, sd_data in report_data["sub_dimensions"].items():
|
||||
# 同类学校均值
|
||||
if same_type_schools and sd_name in all_sub_scores:
|
||||
st_vals = [float(all_sub_scores[sd_name].get(s, 50)) for s in same_type_schools]
|
||||
same_type_avg = round(sum(st_vals) / len(st_vals), 2)
|
||||
else:
|
||||
same_type_avg = round(sd_data["district_avg"], 2)
|
||||
|
||||
# 水平阈值
|
||||
thresholds = LEVEL_THRESHOLDS.get(sd_name, {})
|
||||
|
||||
thermometers[sd_name] = {
|
||||
"score": round(sd_data["score"], 2),
|
||||
"district_avg": round(sd_data["district_avg"], 2),
|
||||
"same_type_avg": same_type_avg,
|
||||
"level": sd_data.get("level", 0),
|
||||
"thresholds": {
|
||||
"level4": thresholds.get("level4", 57),
|
||||
"level3": thresholds.get("level3", 50),
|
||||
"level2": thresholds.get("level2", 43),
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
"school_name": school,
|
||||
"data": thermometers,
|
||||
}
|
||||
|
||||
def _build_waterfall_chart(self, report_data: Dict) -> Dict:
|
||||
"""
|
||||
D. 进步空间瀑布图
|
||||
展示:如果每个低于水平3的子维度提升到水平3的阈值,总分增加多少
|
||||
让校长看到"改哪几个点收益最大"
|
||||
"""
|
||||
school = report_data["school"]
|
||||
current_total = report_data["overall"]["score"]
|
||||
|
||||
# 找出所有低于水平3的子维度
|
||||
improvement_items = []
|
||||
for sd_name, sd_data in report_data["sub_dimensions"].items():
|
||||
level = sd_data.get("level", 0)
|
||||
if level < 3:
|
||||
current_score = sd_data["score"]
|
||||
# 提升到水平3的阈值
|
||||
target_score = LEVEL_THRESHOLDS.get(sd_name, {}).get("level3", 50)
|
||||
gap = round(target_score - current_score, 2)
|
||||
if gap > 0:
|
||||
improvement_items.append({
|
||||
"name": sd_name,
|
||||
"parent": sd_data.get("parent_dimension", ""),
|
||||
"current_score": round(current_score, 2),
|
||||
"target_score": round(target_score, 2),
|
||||
"gap": gap,
|
||||
"current_level": level,
|
||||
})
|
||||
|
||||
# 按收益从大到小排序
|
||||
improvement_items.sort(key=lambda x: x["gap"], reverse=True)
|
||||
|
||||
# 估算总分提升(简化:假设20个子维度等权重影响总分)
|
||||
total_sub_dims = len(report_data["sub_dimensions"])
|
||||
cumulative = current_total
|
||||
waterfall_steps = [{"name": "当前总分", "value": round(current_total, 2), "type": "current"}]
|
||||
|
||||
for item in improvement_items:
|
||||
# 粗略估算:子维度提升gap分 → 总分提升 gap / total_sub_dims * 权重
|
||||
# 实际PCA权重不同,此处用等权近似
|
||||
estimated_gain = round(item["gap"] / total_sub_dims, 2)
|
||||
cumulative += estimated_gain
|
||||
waterfall_steps.append({
|
||||
"name": item["name"],
|
||||
"value": round(estimated_gain, 2),
|
||||
"type": "gain",
|
||||
"detail": f"从水平{item['current_level']}→水平3 (+{item['gap']}分)",
|
||||
})
|
||||
|
||||
waterfall_steps.append({"name": "潜在总分", "value": round(cumulative, 2), "type": "potential"})
|
||||
|
||||
return {
|
||||
"school_name": school,
|
||||
"current_total": round(current_total, 2),
|
||||
"potential_total": round(cumulative, 2),
|
||||
"total_gain": round(cumulative - current_total, 2),
|
||||
"steps": waterfall_steps,
|
||||
"improvements": improvement_items,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _to_namespace(d: Dict) -> Dict:
|
||||
"""将dict转为可用点号访问的对象(Jinja2兼容)"""
|
||||
class Namespace(dict):
|
||||
def __getattr__(self, key):
|
||||
try:
|
||||
return self[key]
|
||||
except KeyError:
|
||||
return None
|
||||
return Namespace(d) if isinstance(d, dict) else d
|
||||
|
||||
# ========== AI 对话助手配置 ==========
|
||||
|
||||
@staticmethod
|
||||
def _xor_encode(plaintext: str, xor_key: str) -> str:
|
||||
"""XOR + Base64 编码(简单混淆,防止明文暴露)"""
|
||||
xor_bytes = bytearray(len(plaintext))
|
||||
for i, ch in enumerate(plaintext):
|
||||
xor_bytes[i] = ord(ch) ^ ord(xor_key[i % len(xor_key)])
|
||||
return base64.b64encode(xor_bytes).decode('ascii')
|
||||
|
||||
def _build_chat_config(self, report_data: Dict) -> tuple:
|
||||
"""
|
||||
构建 AI 对话助手的配置和上下文数据
|
||||
|
||||
Returns:
|
||||
(chat_config dict, report_data_json string)
|
||||
"""
|
||||
# 从 backend/app/config.py 读取 LLM 设置(全项目唯一真相源)
|
||||
_backend_path = str(Path(__file__).parent.parent.parent.parent / "backend")
|
||||
if _backend_path not in sys.path:
|
||||
sys.path.insert(0, _backend_path)
|
||||
from app.config import LLM_BASE_URL, LLM_API_KEY, LLM_MODEL
|
||||
|
||||
# 生成随机 XOR key(每次渲染不同)
|
||||
xor_key = ''.join(random.choices(string.ascii_letters + string.digits, k=16))
|
||||
|
||||
# XOR 编码 API Key
|
||||
api_key_encoded = self._xor_encode(LLM_API_KEY, xor_key)
|
||||
|
||||
chat_config = {
|
||||
"api_key_encoded": api_key_encoded,
|
||||
"xor_key": xor_key,
|
||||
"api_base_url": LLM_BASE_URL,
|
||||
"model": LLM_MODEL,
|
||||
"school_name": report_data["school"],
|
||||
}
|
||||
|
||||
# 构建精简版 report_data JSON(去掉超大的 all_schools 数据以节省体积)
|
||||
slim_data = {
|
||||
"school": report_data.get("school"),
|
||||
"school_info": report_data.get("school_info", {}),
|
||||
"overall": report_data.get("overall", {}),
|
||||
"dimensions": report_data.get("dimensions", {}),
|
||||
"sub_dimensions": report_data.get("sub_dimensions", {}),
|
||||
}
|
||||
|
||||
# 清理 numpy 类型
|
||||
def clean(obj):
|
||||
if isinstance(obj, dict):
|
||||
return {k: clean(v) for k, v in obj.items()}
|
||||
elif isinstance(obj, list):
|
||||
return [clean(v) for v in obj]
|
||||
elif isinstance(obj, (np.integer,)):
|
||||
return int(obj)
|
||||
elif isinstance(obj, (np.floating,)):
|
||||
return round(float(obj), 4)
|
||||
elif isinstance(obj, np.ndarray):
|
||||
return obj.tolist()
|
||||
elif isinstance(obj, float):
|
||||
return round(obj, 4)
|
||||
return obj
|
||||
|
||||
report_data_json = json.dumps(clean(slim_data), ensure_ascii=False)
|
||||
|
||||
return chat_config, report_data_json
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,374 @@
|
||||
"""
|
||||
二期统计引擎 (era2)
|
||||
支持全市基准标准化 + 同类学校均值计算
|
||||
"""
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from sklearn.decomposition import PCA
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
from sklearn.cluster import KMeans
|
||||
from scipy import stats
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from config_era2 import (
|
||||
PCA_MEAN, PCA_STD, LEVEL_THRESHOLDS, DIMENSION_FRAMEWORK, SUBJECTS,
|
||||
SCHOOL_TYPE_MAP,
|
||||
)
|
||||
try:
|
||||
from config_era2 import CLUSTER_CONFIG
|
||||
except ImportError:
|
||||
CLUSTER_CONFIG = {}
|
||||
try:
|
||||
from config_era2 import LEVEL_DESCRIPTIONS
|
||||
except ImportError:
|
||||
LEVEL_DESCRIPTIONS = {}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StatsEngineEra2:
|
||||
"""二期统计引擎"""
|
||||
|
||||
def __init__(self):
|
||||
self._dimension_scores: Optional[pd.DataFrame] = None
|
||||
self._sub_dimension_scores: Optional[pd.DataFrame] = None
|
||||
|
||||
def standardize_scores(self, raw_scores: np.ndarray) -> np.ndarray:
|
||||
if len(raw_scores) < 2:
|
||||
return np.full_like(raw_scores, PCA_MEAN, dtype=float)
|
||||
mean = np.nanmean(raw_scores)
|
||||
std = np.nanstd(raw_scores, ddof=1)
|
||||
if std == 0 or np.isnan(std):
|
||||
return np.full_like(raw_scores, PCA_MEAN, dtype=float)
|
||||
return (raw_scores - mean) / std * PCA_STD + PCA_MEAN
|
||||
|
||||
def pca_compose(self, data_matrix: pd.DataFrame) -> np.ndarray:
|
||||
filled = data_matrix.fillna(data_matrix.mean())
|
||||
if filled.shape[1] == 0:
|
||||
return np.full(filled.shape[0], PCA_MEAN)
|
||||
if filled.shape[1] == 1:
|
||||
return self.standardize_scores(filled.iloc[:, 0].values)
|
||||
scaler = StandardScaler()
|
||||
scaled = scaler.fit_transform(filled)
|
||||
n_components = min(1, filled.shape[1], filled.shape[0])
|
||||
pca = PCA(n_components=n_components)
|
||||
scores = pca.fit_transform(scaled)[:, 0]
|
||||
loadings = pca.components_[0]
|
||||
if np.sum(loadings) < 0:
|
||||
scores = -scores
|
||||
return self.standardize_scores(scores)
|
||||
|
||||
def determine_level(self, score: float, dimension: str) -> int:
|
||||
thresholds = LEVEL_THRESHOLDS.get(dimension, {})
|
||||
if not thresholds:
|
||||
if score > 55: return 4
|
||||
elif score > 50: return 3
|
||||
elif score > 45: return 2
|
||||
else: return 1
|
||||
if score > thresholds["level4"]: return 4
|
||||
elif score > thresholds["level3"]: return 3
|
||||
elif score > thresholds["level2"]: return 2
|
||||
else: return 1
|
||||
|
||||
def get_level_description(self, dimension: str, level: int) -> str:
|
||||
descriptions = LEVEL_DESCRIPTIONS.get(dimension, {})
|
||||
return descriptions.get(level, f"水平{level}")
|
||||
|
||||
def compute_dimension_scores(self, school_raw_scores: Dict[str, Dict[str, List[float]]]) -> pd.DataFrame:
|
||||
"""原始赋分 → nanmean → z-score标准化(旧方法,兼容保留)"""
|
||||
schools = list(school_raw_scores.keys())
|
||||
all_sub_dims = []
|
||||
for dim, info in DIMENSION_FRAMEWORK.items():
|
||||
all_sub_dims.extend(info["sub_dimensions"])
|
||||
|
||||
raw_matrix = {}
|
||||
for sub_dim in all_sub_dims:
|
||||
values = []
|
||||
for school in schools:
|
||||
scores = school_raw_scores.get(school, {}).get(sub_dim, [])
|
||||
values.append(np.nanmean(scores) if scores else np.nan)
|
||||
raw_matrix[sub_dim] = values
|
||||
|
||||
raw_df = pd.DataFrame(raw_matrix, index=schools)
|
||||
|
||||
result = pd.DataFrame(index=schools)
|
||||
for sub_dim in all_sub_dims:
|
||||
if sub_dim in raw_df.columns:
|
||||
result[sub_dim] = self.standardize_scores(raw_df[sub_dim].values)
|
||||
else:
|
||||
result[sub_dim] = PCA_MEAN
|
||||
|
||||
self._sub_dimension_scores = result
|
||||
return result
|
||||
|
||||
def compute_dimension_scores_pca(self, pca_sub_scores: pd.DataFrame) -> pd.DataFrame:
|
||||
"""
|
||||
接受PCA引擎的输出(已经是子维度得分的DataFrame),直接使用。
|
||||
PCA引擎内部已完成 Z标准化 → PCA → ×10+50 的全流程。
|
||||
|
||||
Args:
|
||||
pca_sub_scores: PcaScoringEngineEra2.compute_all() 的输出
|
||||
DataFrame, index=学校, columns=子维度名
|
||||
Returns:
|
||||
与 compute_dimension_scores 相同格式的 DataFrame
|
||||
"""
|
||||
all_sub_dims = []
|
||||
for dim, info in DIMENSION_FRAMEWORK.items():
|
||||
all_sub_dims.extend(info["sub_dimensions"])
|
||||
|
||||
result = pd.DataFrame(index=pca_sub_scores.index)
|
||||
for sub_dim in all_sub_dims:
|
||||
if sub_dim in pca_sub_scores.columns:
|
||||
result[sub_dim] = pca_sub_scores[sub_dim]
|
||||
else:
|
||||
result[sub_dim] = PCA_MEAN
|
||||
|
||||
self._sub_dimension_scores = result
|
||||
return result
|
||||
|
||||
def compute_dimension_aggregates(self, sub_scores: pd.DataFrame) -> pd.DataFrame:
|
||||
result = pd.DataFrame(index=sub_scores.index)
|
||||
for dim, info in DIMENSION_FRAMEWORK.items():
|
||||
sub_dims = [s for s in info["sub_dimensions"] if s in sub_scores.columns]
|
||||
if sub_dims:
|
||||
result[dim] = sub_scores[sub_dims].mean(axis=1)
|
||||
else:
|
||||
result[dim] = PCA_MEAN
|
||||
result["总体得分"] = result[list(DIMENSION_FRAMEWORK.keys())].mean(axis=1)
|
||||
self._dimension_scores = result
|
||||
return result
|
||||
|
||||
def compute_levels(self, sub_scores: pd.DataFrame) -> pd.DataFrame:
|
||||
levels = pd.DataFrame(index=sub_scores.index)
|
||||
for col in sub_scores.columns:
|
||||
levels[col] = sub_scores[col].apply(lambda x: self.determine_level(x, col))
|
||||
return levels
|
||||
|
||||
def cluster_analysis(self, scores: pd.DataFrame, n_clusters: int = 2,
|
||||
dimension_name: Optional[str] = None) -> Dict:
|
||||
"""
|
||||
K-means 聚类分析。
|
||||
|
||||
Args:
|
||||
scores: 学校×子维度 的得分 DataFrame
|
||||
n_clusters: 聚类数(默认2,可通过 CLUSTER_CONFIG 覆盖)
|
||||
dimension_name: 维度名称,用于从 CLUSTER_CONFIG 查询聚类数
|
||||
"""
|
||||
# 从配置覆盖聚类数
|
||||
if dimension_name and dimension_name in CLUSTER_CONFIG:
|
||||
n_clusters = CLUSTER_CONFIG[dimension_name]
|
||||
|
||||
scaler = StandardScaler()
|
||||
scaled = scaler.fit_transform(scores.fillna(PCA_MEAN))
|
||||
n_clusters = min(n_clusters, len(scores))
|
||||
if n_clusters < 2:
|
||||
return {"labels": [0] * len(scores), "centers": scores.values.tolist()}
|
||||
kmeans = KMeans(n_clusters=n_clusters, random_state=42, n_init=10)
|
||||
labels = kmeans.fit_predict(scaled)
|
||||
|
||||
cluster_means = {}
|
||||
for c in range(n_clusters):
|
||||
mask = labels == c
|
||||
cluster_means[c] = scores[mask].mean().to_dict()
|
||||
|
||||
avg_per_cluster = {c: np.mean(list(v.values())) for c, v in cluster_means.items()}
|
||||
sorted_clusters = sorted(avg_per_cluster.items(), key=lambda x: x[1], reverse=True)
|
||||
|
||||
# 命名策略:2类 → 较好/待提升,3类 → 较好/中等/待提升
|
||||
cluster_names = {}
|
||||
if n_clusters == 2:
|
||||
name_list = ["较好", "待提升"]
|
||||
elif n_clusters == 3:
|
||||
name_list = ["较好", "中等", "待提升"]
|
||||
else:
|
||||
name_list = [f"第{i+1}类" for i in range(n_clusters)]
|
||||
|
||||
for rank, (c, _) in enumerate(sorted_clusters):
|
||||
cluster_names[c] = name_list[rank] if rank < len(name_list) else f"第{rank+1}类"
|
||||
|
||||
return {
|
||||
"labels": labels.tolist(),
|
||||
"n_clusters": n_clusters,
|
||||
"school_clusters": {
|
||||
school: cluster_names[labels[i]]
|
||||
for i, school in enumerate(scores.index)
|
||||
},
|
||||
"cluster_means": cluster_means,
|
||||
"cluster_names": cluster_names,
|
||||
}
|
||||
|
||||
def t_test_vs_mean(self, school_scores: np.ndarray, ref_mean: float) -> Dict:
|
||||
scores = school_scores[~np.isnan(school_scores)]
|
||||
if len(scores) < 2:
|
||||
return {"t": np.nan, "p": np.nan, "significant": False, "n": len(scores)}
|
||||
t_stat, p_value = stats.ttest_1samp(scores, ref_mean)
|
||||
return {
|
||||
"t": round(float(t_stat), 3),
|
||||
"p": round(float(p_value), 4),
|
||||
"significant": float(p_value) < 0.05,
|
||||
"n": len(scores),
|
||||
}
|
||||
|
||||
def correlation_analysis(self, dim_scores: pd.DataFrame) -> pd.DataFrame:
|
||||
dim_cols = [c for c in dim_scores.columns if c in DIMENSION_FRAMEWORK]
|
||||
return dim_scores[dim_cols].corr()
|
||||
|
||||
def compute_school_report_data(self, school: str,
|
||||
sub_scores: pd.DataFrame,
|
||||
dim_scores: pd.DataFrame,
|
||||
district_schools: Optional[List[str]] = None) -> Dict:
|
||||
"""
|
||||
生成单校报告数据。
|
||||
|
||||
sub_scores / dim_scores: 全市所有学校的标准化分数(全市基准)
|
||||
district_schools: 该学校所在区的学校列表(用于计算区均值和区内排名)
|
||||
如果为None,则用sub_scores中所有学校
|
||||
"""
|
||||
all_schools = list(sub_scores.index)
|
||||
if school not in all_schools:
|
||||
raise ValueError(f"学校 '{school}' 不在数据中")
|
||||
|
||||
# 确定区内学校列表
|
||||
if district_schools is None:
|
||||
district_schools = all_schools
|
||||
district_schools = [s for s in district_schools if s in all_schools]
|
||||
|
||||
# 区内分数切片
|
||||
dist_sub = sub_scores.loc[district_schools]
|
||||
dist_dim = dim_scores.loc[district_schools]
|
||||
|
||||
# 区均值
|
||||
district_avg_sub = dist_sub.mean()
|
||||
district_avg_dim = dist_dim.mean()
|
||||
|
||||
# 同类学校均值(从全市SCHOOL_TYPE_MAP中找同类型学校)
|
||||
school_info_data = SCHOOL_TYPE_MAP.get(school, {})
|
||||
school_type = school_info_data.get("type", "")
|
||||
same_type_schools = [
|
||||
s for s in all_schools
|
||||
if SCHOOL_TYPE_MAP.get(s, {}).get("type") == school_type and school_type
|
||||
]
|
||||
if len(same_type_schools) >= 2:
|
||||
same_type_avg_sub = sub_scores.loc[same_type_schools].mean()
|
||||
same_type_avg_dim = dim_scores.loc[same_type_schools].mean()
|
||||
else:
|
||||
same_type_avg_sub = district_avg_sub
|
||||
same_type_avg_dim = district_avg_dim
|
||||
|
||||
# 构建 school_info(从 SCHOOL_TYPE_MAP 获取)
|
||||
school_info = {}
|
||||
if school_info_data:
|
||||
school_info = {
|
||||
"type": school_info_data.get("type", ""),
|
||||
"code": school_info_data.get("code", ""),
|
||||
"nature": school_info_data.get("nature", ""),
|
||||
"feature": school_info_data.get("area", ""), # 所处地区作为feature
|
||||
}
|
||||
|
||||
levels = self.compute_levels(sub_scores)
|
||||
|
||||
# 聚类只在区内做
|
||||
dim_cols = [c for c in dim_scores.columns if c in DIMENSION_FRAMEWORK]
|
||||
overall_cluster = self.cluster_analysis(dist_dim[dim_cols], dimension_name="总体")
|
||||
|
||||
dim_clusters = {}
|
||||
for dim, info in DIMENSION_FRAMEWORK.items():
|
||||
sub_dims = [s for s in info["sub_dimensions"] if s in sub_scores.columns]
|
||||
if sub_dims:
|
||||
dim_clusters[dim] = self.cluster_analysis(dist_sub[sub_dims], dimension_name=dim)
|
||||
|
||||
correlation = self.correlation_analysis(dist_dim)
|
||||
|
||||
# 区内排名
|
||||
school_score = float(dist_dim.loc[school, "总体得分"])
|
||||
rank_in_district = int((dist_dim["总体得分"] >= school_score).sum())
|
||||
|
||||
# 全市排名(不分类型,所有参与监测的学校)
|
||||
rank_in_city = int((dim_scores["总体得分"] >= school_score).sum())
|
||||
total_schools_in_city = len(all_schools)
|
||||
|
||||
# 全市同类排名
|
||||
if len(same_type_schools) >= 2:
|
||||
st_dim = dim_scores.loc[same_type_schools]
|
||||
rank_in_same_type = int((st_dim["总体得分"] >= school_score).sum())
|
||||
total_same_type = len(same_type_schools)
|
||||
else:
|
||||
rank_in_same_type = rank_in_district
|
||||
total_same_type = len(district_schools)
|
||||
|
||||
report = {
|
||||
"school": school,
|
||||
"school_info": school_info,
|
||||
"overall": {
|
||||
"score": round(school_score, 2),
|
||||
"district_avg": round(float(district_avg_dim["总体得分"]), 2),
|
||||
"same_type_avg": round(float(same_type_avg_dim.get("总体得分", PCA_MEAN)), 2),
|
||||
"rank_in_district": rank_in_district,
|
||||
"total_schools": len(district_schools),
|
||||
"rank_in_city": rank_in_city,
|
||||
"total_schools_in_city": total_schools_in_city,
|
||||
"cluster": overall_cluster["school_clusters"].get(school, ""),
|
||||
"school_type": school_type,
|
||||
"same_type_count": total_same_type,
|
||||
"rank_in_same_type": rank_in_same_type,
|
||||
},
|
||||
"dimensions": {},
|
||||
"sub_dimensions": {},
|
||||
"correlation": correlation.to_dict(),
|
||||
"all_schools_dim_scores": dist_dim.to_dict(),
|
||||
"all_schools_sub_scores": dist_sub.to_dict(),
|
||||
}
|
||||
|
||||
for dim in DIMENSION_FRAMEWORK:
|
||||
score = float(dist_dim.loc[school, dim])
|
||||
d_avg = float(district_avg_dim[dim])
|
||||
st_avg = float(same_type_avg_dim.get(dim, PCA_MEAN))
|
||||
|
||||
sub_dims = DIMENSION_FRAMEWORK[dim]["sub_dimensions"]
|
||||
sub_vals = np.array([float(sub_scores.loc[school, s]) for s in sub_dims if s in sub_scores.columns])
|
||||
t_test = self.t_test_vs_mean(sub_vals, d_avg)
|
||||
|
||||
report["dimensions"][dim] = {
|
||||
"score": round(score, 2),
|
||||
"district_avg": round(d_avg, 2),
|
||||
"same_type_avg": round(st_avg, 2),
|
||||
"diff_district": round(score - d_avg, 2),
|
||||
"rank_in_district": int((dist_dim[dim] >= score).sum()),
|
||||
"rank_in_city": int((dim_scores[dim] >= score).sum()),
|
||||
"t_test_vs_district": t_test,
|
||||
"cluster": dim_clusters.get(dim, {}).get("school_clusters", {}).get(school, ""),
|
||||
}
|
||||
|
||||
for dim, info in DIMENSION_FRAMEWORK.items():
|
||||
for sub_dim in info["sub_dimensions"]:
|
||||
if sub_dim not in sub_scores.columns:
|
||||
continue
|
||||
score = float(sub_scores.loc[school, sub_dim])
|
||||
d_avg = float(district_avg_sub[sub_dim])
|
||||
level = int(levels.loc[school, sub_dim])
|
||||
# 区内排名
|
||||
rank = int((dist_sub[sub_dim] >= score).sum())
|
||||
# 全市排名
|
||||
rank_city = int((sub_scores[sub_dim] >= score).sum())
|
||||
# 区内水平分布
|
||||
dist_levels = levels.loc[district_schools]
|
||||
dim_levels = dist_levels[sub_dim]
|
||||
level_dist = {f"水平{i}": int((dim_levels == i).sum()) for i in range(1, 5)}
|
||||
|
||||
report["sub_dimensions"][sub_dim] = {
|
||||
"parent_dimension": dim,
|
||||
"score": round(score, 2),
|
||||
"district_avg": round(d_avg, 2),
|
||||
"diff_district": round(score - d_avg, 2),
|
||||
"rank_in_district": rank,
|
||||
"rank_in_city": rank_city,
|
||||
"level": level,
|
||||
"level_description": self.get_level_description(sub_dim, level),
|
||||
"level_distribution": level_dist,
|
||||
}
|
||||
|
||||
return report
|
||||
@@ -0,0 +1,664 @@
|
||||
"""
|
||||
数据溯源引擎 — 生成单校从原始数据到最终得分的完整计算链路
|
||||
|
||||
设计原则:
|
||||
- 不侵入现有PCA引擎,独立读取数据并重建中间过程
|
||||
- 输出JSON结构,前端R3F组件直接消费
|
||||
- 支持缓存(同一学校的trace数据不会频繁变化)
|
||||
"""
|
||||
import json
|
||||
import hashlib
|
||||
import logging
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
from sklearn.decomposition import PCA
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from config_era2 import (
|
||||
PCA_MEAN, PCA_STD, DIMENSION_FRAMEWORK, LEVEL_THRESHOLDS,
|
||||
LEVEL_DESCRIPTIONS, SUBJECTS, SCHOOL_TYPE_MAP,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 缓存目录
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent.parent
|
||||
TRACE_CACHE_DIR = PROJECT_ROOT / "output" / "trace_cache"
|
||||
|
||||
|
||||
class TraceEngine:
|
||||
"""数据溯源引擎:为单校生成完整的6阶段计算链路"""
|
||||
|
||||
def __init__(self, data_engine, pca_engine, stats_engine,
|
||||
sub_scores: pd.DataFrame, dim_scores: pd.DataFrame):
|
||||
self.de = data_engine
|
||||
self.pca = pca_engine
|
||||
self.stats = stats_engine
|
||||
self.sub_scores = sub_scores
|
||||
self.dim_scores = dim_scores
|
||||
|
||||
def compute_trace(self, school: str, district_schools: List[str],
|
||||
use_cache: bool = True) -> Dict:
|
||||
"""
|
||||
主入口:生成单校的完整计算链路
|
||||
|
||||
Returns:
|
||||
{school, school_info, stages: {raw_data, scoring, pca_detail, standardized, levels, dimensions, overall}}
|
||||
"""
|
||||
# 缓存
|
||||
if use_cache:
|
||||
cached = self._load_cache(school)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
logger.info(f"[Trace] 生成 {school} 的计算链路...")
|
||||
|
||||
all_schools = list(self.sub_scores.index)
|
||||
dist_schools = [s for s in district_schools if s in all_schools]
|
||||
|
||||
# 阶段0:原始数据概览
|
||||
stage_raw = self._trace_raw_data(school)
|
||||
|
||||
# 阶段1:赋分过程 + 阶段2: PCA细节
|
||||
stage_scoring, stage_pca = self._trace_scoring_and_pca(school, all_schools)
|
||||
|
||||
# 阶段3:标准化后得分
|
||||
stage_std = self._trace_standardized(school, dist_schools)
|
||||
|
||||
# 阶段4:水平判定
|
||||
stage_levels = self._trace_levels(school)
|
||||
|
||||
# 阶段5:维度聚合 → 总分
|
||||
stage_dims, stage_overall = self._trace_dimensions(school, dist_schools)
|
||||
|
||||
# 全市对比数据(用于标准化分布可视化)
|
||||
all_schools_overall = {}
|
||||
for s in dist_schools:
|
||||
if s in self.dim_scores.index and "总体得分" in self.dim_scores.columns:
|
||||
all_schools_overall[s] = round(float(self.dim_scores.loc[s, "总体得分"]), 2)
|
||||
|
||||
result = {
|
||||
"school": school,
|
||||
"school_info": SCHOOL_TYPE_MAP.get(school, {}),
|
||||
"district_school_count": len(dist_schools),
|
||||
"city_school_count": len(all_schools),
|
||||
"stages": {
|
||||
"raw_data": stage_raw,
|
||||
"scoring": stage_scoring,
|
||||
"pca_detail": stage_pca,
|
||||
"standardized": stage_std,
|
||||
"levels": stage_levels,
|
||||
"dimensions": stage_dims,
|
||||
"overall": stage_overall,
|
||||
},
|
||||
"all_schools_overall": all_schools_overall,
|
||||
}
|
||||
|
||||
self._save_cache(school, result)
|
||||
return result
|
||||
|
||||
# ====================================================================
|
||||
# 阶段0:原始数据概览
|
||||
# ====================================================================
|
||||
|
||||
def _trace_raw_data(self, school: str) -> Dict:
|
||||
"""展示该校的原始数据概况"""
|
||||
# B表数据
|
||||
course_df = self._get_course(school)
|
||||
b_fields = len(course_df)
|
||||
b_sample = []
|
||||
if len(course_df) > 0:
|
||||
sample_rows = course_df.head(12)
|
||||
for _, r in sample_rows.iterrows():
|
||||
b_sample.append({
|
||||
"name": str(r.get("字段名称", "")),
|
||||
"value": _safe_value(r.get("字段取值", "")),
|
||||
"type": _guess_type(r.get("字段取值", "")),
|
||||
})
|
||||
|
||||
# C表数据
|
||||
c_total = 0
|
||||
c_subjects = []
|
||||
for subj in SUBJECTS:
|
||||
sub_df = self._get_subject(school, subj)
|
||||
count = len(sub_df)
|
||||
c_total += count
|
||||
if count > 0:
|
||||
c_subjects.append({"subject": subj, "field_count": count})
|
||||
|
||||
c_sample = []
|
||||
# 取第一个有数据的学科的前几行
|
||||
for subj in SUBJECTS:
|
||||
sub_df = self._get_subject(school, subj)
|
||||
if len(sub_df) > 0:
|
||||
for _, r in sub_df.head(8).iterrows():
|
||||
c_sample.append({
|
||||
"subject": subj,
|
||||
"name": str(r.get("字段名称", "")),
|
||||
"value": _safe_value(r.get("字段取值", "")),
|
||||
"type": _guess_type(r.get("字段取值", "")),
|
||||
})
|
||||
break
|
||||
|
||||
return {
|
||||
"b_table": {
|
||||
"field_count": b_fields,
|
||||
"sample_fields": b_sample,
|
||||
},
|
||||
"c_table": {
|
||||
"field_count": c_total,
|
||||
"subject_count": len(c_subjects),
|
||||
"subjects": c_subjects,
|
||||
"sample_fields": c_sample,
|
||||
},
|
||||
"total_fields": b_fields + c_total,
|
||||
}
|
||||
|
||||
# ====================================================================
|
||||
# 阶段1 & 2:赋分 + PCA
|
||||
# ====================================================================
|
||||
|
||||
def _trace_scoring_and_pca(self, school: str, all_schools: List[str]) -> tuple:
|
||||
"""
|
||||
为每个子维度重建赋分过程和PCA细节。
|
||||
|
||||
策略:对每个子维度,分别计算该子维度的赋分矩阵,
|
||||
记录该校的具体输入值、赋分规则和PCA参数。
|
||||
"""
|
||||
scoring = {}
|
||||
pca_detail = {}
|
||||
|
||||
# 按维度框架遍历每个子维度
|
||||
sub_dim_methods = {
|
||||
"国家标准遵循": self._trace_national_standard,
|
||||
"课程结构建设": self._trace_course_structure,
|
||||
"课程规范落实": self._trace_school_level_generic,
|
||||
"教学方式变革": self._trace_subject_level_generic,
|
||||
"作业设计与管理变革": self._trace_subject_level_generic,
|
||||
"学科发展的个性化辅导": self._trace_subject_level_generic,
|
||||
"学生生涯发展指导": self._trace_school_level_generic,
|
||||
"培训支持": self._trace_subject_level_generic,
|
||||
"教研支持": self._trace_subject_level_generic,
|
||||
"项目支持": self._trace_subject_level_generic,
|
||||
"科学评价观": self._trace_subject_level_generic,
|
||||
"学业质量评估": self._trace_subject_level_generic,
|
||||
"综合素质评估": self._trace_school_level_generic,
|
||||
"实践活动评估": self._trace_subject_level_generic,
|
||||
"区域推进": self._trace_school_level_generic,
|
||||
"环境支持": self._trace_school_level_generic,
|
||||
"资源支持": self._trace_school_level_generic,
|
||||
"教学方式创新": self._trace_subject_level_generic,
|
||||
"评价精准化与个性化": self._trace_school_level_generic,
|
||||
"课程迭代优化": self._trace_school_level_generic,
|
||||
}
|
||||
|
||||
for dim_name, info in DIMENSION_FRAMEWORK.items():
|
||||
for sub_dim in info["sub_dimensions"]:
|
||||
method = sub_dim_methods.get(sub_dim, self._trace_generic_fallback)
|
||||
try:
|
||||
s_info, p_info = method(sub_dim, school, all_schools)
|
||||
except Exception as e:
|
||||
logger.warning(f"[Trace] {sub_dim} trace failed: {e}")
|
||||
s_info = {"method": "unknown", "error": str(e)}
|
||||
p_info = {}
|
||||
|
||||
# 追加最终得分
|
||||
final_score = float(self.sub_scores.loc[school, sub_dim]) if sub_dim in self.sub_scores.columns else None
|
||||
s_info["final_score"] = round(final_score, 2) if final_score is not None else None
|
||||
s_info["parent_dimension"] = dim_name
|
||||
|
||||
scoring[sub_dim] = s_info
|
||||
pca_detail[sub_dim] = p_info
|
||||
|
||||
return scoring, pca_detail
|
||||
|
||||
def _trace_national_standard(self, sub_dim: str, school: str,
|
||||
all_schools: List[str]) -> tuple:
|
||||
"""国家标准遵循的特殊trace"""
|
||||
BXIU_STD = {"语文": 8, "数学": 8, "英语": 6, "思想政治": 6, "历史": 4, "地理": 4,
|
||||
"物理": 6, "化学": 4, "生命科学": 4, "体育": 12, "技术": 6, "艺术": 6}
|
||||
MERGE_MAP = {
|
||||
"信息技术": "技术", "通用技术": "技术", "劳动技术": "技术",
|
||||
"音乐": "艺术", "美术": "艺术", "生物学": "生命科学", "体育与健康": "体育",
|
||||
}
|
||||
SPSS_12 = list(BXIU_STD.keys())
|
||||
|
||||
# 获取该校课时
|
||||
hours_df = self.pca._get_weekly_hours(school)
|
||||
merged = {s: {"必修": 0, "选必": 0, "选修": 0} for s in SPSS_12}
|
||||
if len(hours_df) > 0:
|
||||
for _, r in hours_df.iterrows():
|
||||
subj = r.get("学科", "")
|
||||
mapped = MERGE_MAP.get(subj, subj)
|
||||
if mapped not in merged:
|
||||
continue
|
||||
field = r["字段名称"]
|
||||
val = r["字段取值"]
|
||||
if pd.isna(val):
|
||||
continue
|
||||
if "必修课周课时" in field and "选择性" not in field:
|
||||
merged[mapped]["必修"] += val
|
||||
elif "选择性必修" in field:
|
||||
merged[mapped]["选必"] += val
|
||||
elif "选修课周课时" in field:
|
||||
merged[mapped]["选修"] += val
|
||||
|
||||
# 必修分档评分
|
||||
inputs = []
|
||||
for s in SPSS_12:
|
||||
actual = merged[s]["必修"]
|
||||
std = BXIU_STD[s]
|
||||
if actual == 0:
|
||||
score = 0.0
|
||||
rule = "低于标准->0"
|
||||
elif abs(actual - std) <= 1.0:
|
||||
score = 2.0
|
||||
rule = "一致->2"
|
||||
elif actual > std:
|
||||
score = 1.0
|
||||
rule = "高于标准->1"
|
||||
else:
|
||||
score = 0.0
|
||||
rule = "低于标准->0"
|
||||
inputs.append({
|
||||
"name": f"{s}必修课时",
|
||||
"raw": actual,
|
||||
"standard": std,
|
||||
"score": score,
|
||||
"rule": rule,
|
||||
})
|
||||
|
||||
total_xb = sum(merged[s]["选必"] for s in SPSS_12)
|
||||
total_xx = sum(merged[s]["选修"] for s in SPSS_12)
|
||||
|
||||
scoring_info = {
|
||||
"method": "PCA(12学科必修分档) + Z(选必达标) + Z(选修达标) -> 均值",
|
||||
"inputs": inputs,
|
||||
"sub_factors": [
|
||||
{"name": "必修PCA", "input_count": 12, "type": "PCA"},
|
||||
{"name": "选必达标", "raw": total_xb, "threshold": 42,
|
||||
"met": total_xb >= 42, "type": "Z-score"},
|
||||
{"name": "选修达标", "raw": total_xx, "threshold": 14,
|
||||
"met": total_xx >= 14, "type": "Z-score"},
|
||||
],
|
||||
}
|
||||
|
||||
# PCA细节:构建全市必修矩阵
|
||||
bx_rows = {}
|
||||
for s in all_schools:
|
||||
h_df = self.pca._get_weekly_hours(s)
|
||||
m = {subj: {"必修": 0} for subj in SPSS_12}
|
||||
if len(h_df) > 0:
|
||||
for _, r in h_df.iterrows():
|
||||
subj = r.get("学科", "")
|
||||
mapped = MERGE_MAP.get(subj, subj)
|
||||
if mapped not in m:
|
||||
continue
|
||||
field = r["字段名称"]
|
||||
val = r["字段取值"]
|
||||
if pd.isna(val):
|
||||
continue
|
||||
if "必修课周课时" in field and "选择性" not in field:
|
||||
m[mapped]["必修"] += val
|
||||
row = {}
|
||||
for subj in SPSS_12:
|
||||
actual = m[subj]["必修"]
|
||||
std_val = BXIU_STD[subj]
|
||||
if actual == 0:
|
||||
row[subj] = 0.0
|
||||
elif abs(actual - std_val) <= 1.0:
|
||||
row[subj] = 2.0
|
||||
elif actual > std_val:
|
||||
row[subj] = 1.0
|
||||
else:
|
||||
row[subj] = 0.0
|
||||
bx_rows[s] = row
|
||||
|
||||
matrix = pd.DataFrame(bx_rows).T
|
||||
pca_info = self._extract_pca_details(matrix, school, "必修课PCA")
|
||||
|
||||
return scoring_info, pca_info
|
||||
|
||||
def _trace_course_structure(self, sub_dim: str, school: str,
|
||||
all_schools: List[str]) -> tuple:
|
||||
"""课程结构建设的trace(3组PCA)"""
|
||||
scoring_info = {
|
||||
"method": "PCA(学科课程结构) + PCA(校本特色) + PCA(综合实践) -> 均值",
|
||||
"sub_factors": [
|
||||
{"name": "学科类课程结构PCA", "type": "PCA",
|
||||
"description": "必修/选必/选修课时比例偏离度"},
|
||||
{"name": "校本特色课程PCA", "type": "PCA",
|
||||
"description": "选修课数量+时长"},
|
||||
{"name": "综合实践PCA", "type": "PCA",
|
||||
"description": "党团次数+社考个数+志愿时长+劳动"},
|
||||
],
|
||||
}
|
||||
# 简化的PCA info
|
||||
pca_info = {
|
||||
"type": "multi_factor",
|
||||
"factor_count": 3,
|
||||
"school_count": len(all_schools),
|
||||
"note": "三组因子各自做PCA后取均值",
|
||||
}
|
||||
return scoring_info, pca_info
|
||||
|
||||
def _trace_subject_level_generic(self, sub_dim: str, school: str,
|
||||
all_schools: List[str]) -> tuple:
|
||||
"""C表学科级子维度的通用trace"""
|
||||
# 获取该校在该子维度的最终得分
|
||||
scoring_info = {
|
||||
"method": "学科级赋分 -> 全市Z标准化 -> PCA(第一主成分) -> x10+50 -> 学科均值 -> 学校均值",
|
||||
"data_source": "C表(学科课程实施情况表)",
|
||||
"subject_count": len(SUBJECTS),
|
||||
}
|
||||
|
||||
# 尝试获取该学科的一些原始数据作为示例
|
||||
sample_inputs = []
|
||||
for subj in SUBJECTS[:3]: # 取前3个学科作示例
|
||||
sub_df = self._get_subject(school, subj)
|
||||
if len(sub_df) > 0:
|
||||
for _, r in sub_df.head(3).iterrows():
|
||||
sample_inputs.append({
|
||||
"subject": subj,
|
||||
"name": str(r.get("字段名称", "")),
|
||||
"value": _safe_value(r.get("字段取值", "")),
|
||||
})
|
||||
scoring_info["sample_inputs"] = sample_inputs
|
||||
|
||||
pca_info = {
|
||||
"type": "subject_level",
|
||||
"school_count": len(all_schools),
|
||||
"subject_count": len(SUBJECTS),
|
||||
"pipeline": "题目赋分 -> Z标准化 -> PCA -> x10+50",
|
||||
}
|
||||
return scoring_info, pca_info
|
||||
|
||||
def _trace_school_level_generic(self, sub_dim: str, school: str,
|
||||
all_schools: List[str]) -> tuple:
|
||||
"""B表学校级子维度的通用trace"""
|
||||
scoring_info = {
|
||||
"method": "学校级赋分 -> 全市Z标准化 -> PCA(第一主成分) -> x10+50",
|
||||
"data_source": "B表(课程实施情况表)",
|
||||
}
|
||||
|
||||
# 取一些原始数据作示例
|
||||
course_df = self._get_course(school)
|
||||
sample_inputs = []
|
||||
if len(course_df) > 0:
|
||||
for _, r in course_df.head(6).iterrows():
|
||||
sample_inputs.append({
|
||||
"name": str(r.get("字段名称", "")),
|
||||
"value": _safe_value(r.get("字段取值", "")),
|
||||
})
|
||||
scoring_info["sample_inputs"] = sample_inputs
|
||||
|
||||
pca_info = {
|
||||
"type": "school_level",
|
||||
"school_count": len(all_schools),
|
||||
"pipeline": "赋分 -> Z标准化 -> PCA -> x10+50",
|
||||
}
|
||||
return scoring_info, pca_info
|
||||
|
||||
def _trace_generic_fallback(self, sub_dim: str, school: str,
|
||||
all_schools: List[str]) -> tuple:
|
||||
"""回退方法"""
|
||||
return {"method": "unknown"}, {}
|
||||
|
||||
# ====================================================================
|
||||
# PCA细节提取
|
||||
# ====================================================================
|
||||
|
||||
def _extract_pca_details(self, matrix: pd.DataFrame, school: str,
|
||||
label: str = "PCA") -> Dict:
|
||||
"""从赋分矩阵中提取PCA的详细参数"""
|
||||
matrix = matrix.dropna(axis=1, how="all")
|
||||
if matrix.shape[1] == 0:
|
||||
return {"label": label, "error": "empty_matrix"}
|
||||
|
||||
filled = matrix.copy().infer_objects(copy=False)
|
||||
for col in filled.columns:
|
||||
col_mean = filled[col].mean()
|
||||
if np.isnan(col_mean):
|
||||
col_mean = 0.0
|
||||
filled[col] = filled[col].fillna(col_mean)
|
||||
|
||||
if filled.shape[1] == 1:
|
||||
vals = filled.iloc[:, 0].values.astype(float)
|
||||
school_idx = list(filled.index).index(school) if school in filled.index else -1
|
||||
return {
|
||||
"label": label,
|
||||
"type": "single_variable_z",
|
||||
"variable": str(filled.columns[0]),
|
||||
"school_value": round(float(vals[school_idx]), 4) if school_idx >= 0 else None,
|
||||
"mean": round(float(np.nanmean(vals)), 4),
|
||||
"std": round(float(np.nanstd(vals, ddof=1)), 4),
|
||||
"n_schools": len(vals),
|
||||
}
|
||||
|
||||
# Z标准化
|
||||
scaler = StandardScaler()
|
||||
try:
|
||||
scaled = scaler.fit_transform(filled.values.astype(float))
|
||||
except ValueError:
|
||||
return {"label": label, "error": "scaling_failed"}
|
||||
|
||||
# PCA
|
||||
pca = PCA(n_components=min(1, filled.shape[1], filled.shape[0]))
|
||||
scores = pca.fit_transform(scaled)[:, 0]
|
||||
loadings = pca.components_[0]
|
||||
|
||||
if np.sum(loadings) < 0:
|
||||
scores = -scores
|
||||
loadings = -loadings
|
||||
|
||||
mean = np.mean(scores)
|
||||
std = np.std(scores, ddof=1)
|
||||
|
||||
school_idx = list(filled.index).index(school) if school in filled.index else -1
|
||||
school_raw_score = scores[school_idx] if school_idx >= 0 else None
|
||||
school_std_score = ((school_raw_score - mean) / std * PCA_STD + PCA_MEAN) if (school_raw_score is not None and std > 0) else None
|
||||
|
||||
# Loadings详情
|
||||
loading_details = []
|
||||
for i, col in enumerate(filled.columns):
|
||||
loading_details.append({
|
||||
"variable": str(col),
|
||||
"loading": round(float(loadings[i]), 4),
|
||||
})
|
||||
loading_details.sort(key=lambda x: abs(x["loading"]), reverse=True)
|
||||
|
||||
return {
|
||||
"label": label,
|
||||
"type": "pca",
|
||||
"n_schools": int(filled.shape[0]),
|
||||
"n_variables": int(filled.shape[1]),
|
||||
"explained_variance_ratio": round(float(pca.explained_variance_ratio_[0]), 4),
|
||||
"loadings": loading_details,
|
||||
"school_pca_score": round(float(school_raw_score), 4) if school_raw_score is not None else None,
|
||||
"school_standardized": round(float(school_std_score), 2) if school_std_score is not None else None,
|
||||
"pca_mean": round(float(mean), 4),
|
||||
"pca_std": round(float(std), 4),
|
||||
}
|
||||
|
||||
# ====================================================================
|
||||
# 阶段3:标准化后得分
|
||||
# ====================================================================
|
||||
|
||||
def _trace_standardized(self, school: str, dist_schools: List[str]) -> Dict:
|
||||
"""标准化后的20个子维度得分"""
|
||||
result = {}
|
||||
for dim_name, info in DIMENSION_FRAMEWORK.items():
|
||||
for sub_dim in info["sub_dimensions"]:
|
||||
if sub_dim not in self.sub_scores.columns:
|
||||
continue
|
||||
score = float(self.sub_scores.loc[school, sub_dim])
|
||||
# 区内均值和全市均值
|
||||
dist_vals = self.sub_scores.loc[
|
||||
[s for s in dist_schools if s in self.sub_scores.index], sub_dim
|
||||
]
|
||||
all_vals = self.sub_scores[sub_dim]
|
||||
|
||||
result[sub_dim] = {
|
||||
"score": round(score, 2),
|
||||
"district_avg": round(float(dist_vals.mean()), 2),
|
||||
"city_avg": round(float(all_vals.mean()), 2),
|
||||
"city_std": round(float(all_vals.std()), 2),
|
||||
"diff_district": round(score - float(dist_vals.mean()), 2),
|
||||
"diff_city": round(score - float(all_vals.mean()), 2),
|
||||
"parent_dimension": dim_name,
|
||||
}
|
||||
return result
|
||||
|
||||
# ====================================================================
|
||||
# 阶段4:水平判定
|
||||
# ====================================================================
|
||||
|
||||
def _trace_levels(self, school: str) -> Dict:
|
||||
result = {}
|
||||
for dim_name, info in DIMENSION_FRAMEWORK.items():
|
||||
for sub_dim in info["sub_dimensions"]:
|
||||
if sub_dim not in self.sub_scores.columns:
|
||||
continue
|
||||
score = float(self.sub_scores.loc[school, sub_dim])
|
||||
thresholds = LEVEL_THRESHOLDS.get(sub_dim, {})
|
||||
level = self.stats.determine_level(score, sub_dim)
|
||||
desc = LEVEL_DESCRIPTIONS.get(sub_dim, {}).get(level, "")
|
||||
|
||||
result[sub_dim] = {
|
||||
"score": round(score, 2),
|
||||
"thresholds": {
|
||||
"level2": thresholds.get("level2", 43),
|
||||
"level3": thresholds.get("level3", 50),
|
||||
"level4": thresholds.get("level4", 57),
|
||||
},
|
||||
"level": level,
|
||||
"description": desc,
|
||||
"parent_dimension": dim_name,
|
||||
}
|
||||
return result
|
||||
|
||||
# ====================================================================
|
||||
# 阶段5:维度聚合 → 总分
|
||||
# ====================================================================
|
||||
|
||||
def _trace_dimensions(self, school: str, dist_schools: List[str]) -> tuple:
|
||||
dims = {}
|
||||
for dim_name, info in DIMENSION_FRAMEWORK.items():
|
||||
sub_dims = info["sub_dimensions"]
|
||||
sub_scores = {}
|
||||
for sd in sub_dims:
|
||||
if sd in self.sub_scores.columns:
|
||||
sub_scores[sd] = round(float(self.sub_scores.loc[school, sd]), 2)
|
||||
|
||||
dim_score = float(self.dim_scores.loc[school, dim_name]) if dim_name in self.dim_scores.columns else None
|
||||
dist_dim_vals = self.dim_scores.loc[
|
||||
[s for s in dist_schools if s in self.dim_scores.index], dim_name
|
||||
] if dim_name in self.dim_scores.columns else pd.Series()
|
||||
|
||||
dims[dim_name] = {
|
||||
"sub_scores": sub_scores,
|
||||
"score": round(dim_score, 2) if dim_score is not None else None,
|
||||
"method": "mean(子维度标准化分)",
|
||||
"district_avg": round(float(dist_dim_vals.mean()), 2) if len(dist_dim_vals) > 0 else None,
|
||||
}
|
||||
|
||||
# 总分
|
||||
overall_score = float(self.dim_scores.loc[school, "总体得分"]) if "总体得分" in self.dim_scores.columns else None
|
||||
dist_overall = self.dim_scores.loc[
|
||||
[s for s in dist_schools if s in self.dim_scores.index], "总体得分"
|
||||
] if "总体得分" in self.dim_scores.columns else pd.Series()
|
||||
|
||||
rank = int((dist_overall >= overall_score).sum()) if overall_score is not None and len(dist_overall) > 0 else None
|
||||
|
||||
overall = {
|
||||
"score": round(overall_score, 2) if overall_score is not None else None,
|
||||
"method": "mean(7个维度分)",
|
||||
"district_avg": round(float(dist_overall.mean()), 2) if len(dist_overall) > 0 else None,
|
||||
"rank": rank,
|
||||
"total_schools": len(dist_schools),
|
||||
}
|
||||
|
||||
return dims, overall
|
||||
|
||||
# ====================================================================
|
||||
# 数据访问代理
|
||||
# ====================================================================
|
||||
|
||||
def _get_course(self, school: str) -> pd.DataFrame:
|
||||
return self.pca._get_course(school)
|
||||
|
||||
def _get_subject(self, school: str, subject: str) -> pd.DataFrame:
|
||||
return self.pca._get_subject(school, subject)
|
||||
|
||||
# ====================================================================
|
||||
# 缓存
|
||||
# ====================================================================
|
||||
|
||||
def _load_cache(self, school: str) -> Optional[Dict]:
|
||||
TRACE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cache_file = TRACE_CACHE_DIR / f"{school}_trace.json"
|
||||
if cache_file.exists():
|
||||
try:
|
||||
return json.loads(cache_file.read_text("utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
def _save_cache(self, school: str, data: Dict):
|
||||
TRACE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cache_file = TRACE_CACHE_DIR / f"{school}_trace.json"
|
||||
try:
|
||||
cache_file.write_text(json.dumps(data, ensure_ascii=False, indent=2, default=_json_default), "utf-8")
|
||||
except Exception as e:
|
||||
logger.warning(f"[Trace] Cache write failed for {school}: {e}")
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# 工具函数
|
||||
# ====================================================================
|
||||
|
||||
def _safe_value(v) -> Any:
|
||||
"""将pandas值转为JSON安全类型"""
|
||||
if pd.isna(v):
|
||||
return None
|
||||
if isinstance(v, (np.integer,)):
|
||||
return int(v)
|
||||
if isinstance(v, (np.floating,)):
|
||||
return round(float(v), 4)
|
||||
return str(v)
|
||||
|
||||
|
||||
def _guess_type(v) -> str:
|
||||
"""猜测字段类型"""
|
||||
if pd.isna(v):
|
||||
return "null"
|
||||
s = str(v).strip()
|
||||
try:
|
||||
float(s)
|
||||
return "number"
|
||||
except ValueError:
|
||||
pass
|
||||
if s in ("0", "1", "有", "无", "是", "否"):
|
||||
return "binary"
|
||||
if s in ("已经建成并使用", "已经建成但未使用", "尚未建成", "已建成并使用", "已建成但未使用"):
|
||||
return "ordinal"
|
||||
return "text"
|
||||
|
||||
|
||||
def _json_default(obj):
|
||||
"""JSON序列化兜底"""
|
||||
if isinstance(obj, (np.integer,)):
|
||||
return int(obj)
|
||||
if isinstance(obj, (np.floating,)):
|
||||
return round(float(obj), 4)
|
||||
if isinstance(obj, np.ndarray):
|
||||
return obj.tolist()
|
||||
if isinstance(obj, pd.Timestamp):
|
||||
return str(obj)
|
||||
if isinstance(obj, float) and (np.isnan(obj) or np.isinf(obj)):
|
||||
return None
|
||||
return str(obj)
|
||||
Reference in New Issue
Block a user