Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
364 lines
13 KiB
Python
364 lines
13 KiB
Python
"""
|
||
统计引擎:PCA合成 + 标准化 + 水平判定 + 聚类 + T检验 + 相关性
|
||
将赋分后的原始数据合成为维度得分,并进行统计分析
|
||
"""
|
||
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
|
||
|
||
from ..config import (
|
||
PCA_MEAN, PCA_STD, LEVEL_THRESHOLDS, LEVEL_DESCRIPTIONS,
|
||
DIMENSION_FRAMEWORK, SUBJECTS, SCHOOL_TYPE_MAP,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class StatsEngine:
|
||
"""统计引擎"""
|
||
|
||
def __init__(self):
|
||
self._dimension_scores: Optional[pd.DataFrame] = None
|
||
self._sub_dimension_scores: Optional[pd.DataFrame] = None
|
||
self._subject_dimension_scores: Optional[pd.DataFrame] = None
|
||
|
||
def standardize_scores(self, raw_scores: np.ndarray) -> np.ndarray:
|
||
"""标准化到均值50标准差10"""
|
||
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:
|
||
"""
|
||
PCA合成:将多个变量合成为一个主成分分数
|
||
data_matrix: 行=学校, 列=变量
|
||
返回:合成后的分数(已标准化到50/10)
|
||
"""
|
||
# 处理缺失值:均值填充
|
||
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)
|
||
|
||
# PCA取第一主成分
|
||
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
|
||
|
||
# 标准化到50/10
|
||
return self.standardize_scores(scores)
|
||
|
||
def determine_level(self, score: float, dimension: str) -> int:
|
||
"""根据分数和维度确定水平(1-4)"""
|
||
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:
|
||
"""
|
||
计算所有学校在各三级维度上的得分
|
||
|
||
school_raw_scores: {
|
||
school_name: {
|
||
sub_dimension_name: [score1, score2, ...] # 该维度下各题目的赋分
|
||
}
|
||
}
|
||
|
||
返回 DataFrame: 行=学校, 列=三级维度, 值=标准化得分
|
||
"""
|
||
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)
|
||
|
||
# PCA合成并标准化各维度
|
||
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_aggregates(self, sub_scores: pd.DataFrame) -> pd.DataFrame:
|
||
"""
|
||
从三级维度分数聚合到二级维度(均值)
|
||
|
||
sub_scores: 行=学校, 列=三级维度
|
||
返回: 行=学校, 列=二级维度
|
||
"""
|
||
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) -> Dict:
|
||
"""
|
||
聚类分析
|
||
|
||
scores: 行=学校, 列=维度
|
||
返回: 聚类标签和各类特征
|
||
"""
|
||
# 标准化
|
||
scaler = StandardScaler()
|
||
scaled = scaler.fit_transform(scores.fillna(PCA_MEAN))
|
||
|
||
# KMeans聚类
|
||
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)
|
||
|
||
cluster_names = {}
|
||
for rank, (c, _) in enumerate(sorted_clusters):
|
||
if rank == 0:
|
||
cluster_names[c] = "较好"
|
||
else:
|
||
cluster_names[c] = "待提升"
|
||
|
||
return {
|
||
"labels": labels.tolist(),
|
||
"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:
|
||
"""
|
||
单样本T检验:学校各学科得分 vs 参考均值
|
||
|
||
school_scores: 该学校在某维度各学科的得分
|
||
ref_mean: 参考均值(如区均值、全市均值)
|
||
"""
|
||
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) -> Dict:
|
||
"""
|
||
为某一所学校生成完整的报告数据包
|
||
|
||
返回包含所有统计分析结果的结构化数据
|
||
"""
|
||
schools = list(sub_scores.index)
|
||
if school not in schools:
|
||
raise ValueError(f"学校 '{school}' 不在数据中")
|
||
|
||
school_info = SCHOOL_TYPE_MAP.get(school, {})
|
||
school_type = school_info.get("type", "")
|
||
|
||
# 区均值
|
||
district_avg_sub = sub_scores.mean()
|
||
district_avg_dim = dim_scores.mean()
|
||
|
||
# 同类学校均值
|
||
same_type_schools = [s for s in schools if SCHOOL_TYPE_MAP.get(s, {}).get("type") == school_type]
|
||
if same_type_schools:
|
||
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
|
||
|
||
# 水平判定
|
||
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(dim_scores[dim_cols])
|
||
|
||
# 各二级维度聚类
|
||
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(sub_scores[sub_dims])
|
||
|
||
# 相关性
|
||
correlation = self.correlation_analysis(dim_scores)
|
||
|
||
# 构建报告数据
|
||
report = {
|
||
"school": school,
|
||
"school_info": school_info,
|
||
|
||
# 总体得分
|
||
"overall": {
|
||
"score": round(float(dim_scores.loc[school, "总体得分"]), 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": int((dim_scores["总体得分"] >= dim_scores.loc[school, "总体得分"]).sum()),
|
||
"total_schools": len(schools),
|
||
"cluster": overall_cluster["school_clusters"].get(school, ""),
|
||
},
|
||
|
||
# 二级维度
|
||
"dimensions": {},
|
||
|
||
# 三级维度
|
||
"sub_dimensions": {},
|
||
|
||
# 相关性矩阵
|
||
"correlation": correlation.to_dict(),
|
||
|
||
# 所有学校得分(用于对比)
|
||
"all_schools_dim_scores": dim_scores.to_dict(),
|
||
"all_schools_sub_scores": sub_scores.to_dict(),
|
||
}
|
||
|
||
# 填充二级维度数据
|
||
for dim in DIMENSION_FRAMEWORK:
|
||
score = float(dim_scores.loc[school, dim])
|
||
d_avg = float(district_avg_dim[dim])
|
||
st_avg = float(same_type_avg_dim.get(dim, PCA_MEAN))
|
||
|
||
# T检验:该学校在该维度下各三级维度得分 vs 区均值
|
||
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((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((sub_scores[sub_dim] >= score).sum())
|
||
|
||
# 水平分布统计
|
||
dim_levels = 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,
|
||
"level": level,
|
||
"level_description": self.get_level_description(sub_dim, level),
|
||
"level_distribution": level_dist,
|
||
}
|
||
|
||
return report
|