大语言模型的用户画像构建以及个性化推荐方法

研究目标

在教育数字化转型持续深化的背景下,各级院校已沉淀出覆盖学业表现、出勤状态、校园消费等场景的海量多源异构学生行为数据。如何从高维、碎片化的原始数据中萃取有效学生特征,进而构建兼具可解释性与落地操作性的学生画像体系,是当前教育数据挖掘领域亟待突破的关键问题。

传统学生画像构建多依托专家先验知识与规则引擎,通过预设阈值的硬分箱逻辑完成学生群体的类别划分。该类方法虽具备逻辑清晰、管控便捷的优势,但普遍存在标签粒度粗糙、语义表征能力薄弱、难以挖掘跨维度数据关联等固有局限。近年来,大语言模型在自然语言理解与生成领域展现出突出能力,为教育场景下的智能化画像构建开辟了全新的技术路径。

本研究的核心方向是探索一套融合无监督聚类算法、大语言模型与向量检索技术的教育画像构建及个性化推荐方法,搭建"数据驱动的群体发现---语义赋能的画像阐释---智能适配的干预推荐"的完整技术闭环。

研究问题

  1. 群体结构挖掘:在无先验标签的条件下,K-Means聚类算法能否从多源教育数据中自动识别出具备实际教育意义的学生群体结构?相较于人工规则分箱方法,聚类算法是否能够挖掘出更细粒度的群体特征差异?
  2. 动态画像拓展:学业发展轨迹的时序演化模式(如持续提升、逐步下滑、波动震荡)能否为静态学生画像补充成长性维度的表征?Mann-Kendall趋势检验方法在教育场景的时序数据分析中是否具备适用性与实践价值?
  3. 智能推荐优化:融合SVD矩阵分解、基于物品的协同过滤与大语言模型语义检索的混合推荐策略,能否同时在预测精准度与推荐结果可解释性两个维度上优于传统推荐方法?

数据来源

本研究以"数智教育数据集"为实证数据基础,数据集包含教师信息、学生档案、考勤记录、考勤类型、考试成绩、考试类型、消费流水共7张数据表,覆盖数千名学生的多维度行为全量数据。

技术路线

本研究采用"基础构建---智能增强---落地应用"三阶段递进式技术框架:

  • 第一阶段(画像基础层):原始数据清洗 → 多维度特征工程 → PCA降维处理 → K-Means无监督聚类 → 学生群体画像输出
  • 第二阶段(智能增强层):学业时序趋势分析 → 规则画像与LLM生成画像效果对比 → TF-IDF语义量化表征
  • 第三阶段(推荐应用层):Item-based协同过滤实现 → SVD矩阵分解建模 → RAG增强型推荐策略 → 多维度量化评估

章节导航

本研究按照"数据工程 → 特征构建 → 聚类分群 → 时序分析 → 画像对比 → 推荐系统 → 效果评估"的逻辑主线组织全文,共分为八个章节。

章节 主题 核心方法 主要产出
数据工程与质量评估 缺失分析、分布检验 数据质量报告
特征空间构建与降维 PCA主成分分析 降维后的特征空间
聚类驱动的学生分群 K-Means、轮廓系数、肘部法则 学生群集标签与画像
学业轨迹时序分析 Mann-Kendall趋势检验、迁移矩阵 趋势标签与流动规律
规则画像与LLM画像对比 TF-IDF语义分析、t-SNE降维 两种方法的量化对比
多策略推荐系统 Item-CF、SVD矩阵分解、RAG 融合推荐引擎
推荐效果量化评估 NDCG、多样性指数、RMSE 多指标评估报告
总结与展望 --- 核心发现与未来方向

一、数据工程与质量评估

任何数据分析工作的第一步都是理解数据本身。本章的主要任务包括:

  1. 环境搭建: 加载科学计算、机器学习、自然语言处理等方向的Python依赖库,配置中文字体以支持图表的正确显示,初始化通义千问API客户端。
  2. 数据接入: 从"数智教育数据集"中读取7张CSV数据表,包括教师表、学生档案表、考勤记录表、考勤类型字典表、考试成绩表、考试类型字典表和消费流水表。
  3. 质量诊断: 对各数据表的规模、缺失率、字段分布进行系统性检查,识别数据质量问题(如成绩字段的异常值、Z-Score的大面积缺失等),为后续的数据清洗提供依据。

数据质量是画像构建可靠性的基础。如果原始数据存在严重的缺失或异常,后续的分析结果将缺乏说服力。因此,我们在进入特征工程之前,先对每张表进行全面的"体检"。

1.1 环境搭建与依赖加载

加载科学计算、机器学习、自然语言处理等方向的Python依赖库,配置中文字体以支持图表的正确显示。

python 复制代码
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.font_manager as fm
import seaborn as sns
import warnings, os, json, time, re
from collections import Counter, defaultdict

from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score, silhouette_samples
from sklearn.metrics.pairwise import cosine_similarity
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.manifold import TSNE
from pymannkendall import original_test as mannkendall
from scipy.sparse.linalg import svds
import chromadb

warnings.filterwarnings('ignore')
try:
    get_ipython().run_line_magic('matplotlib', 'inline')
except:
    plt.switch_backend('Agg')

font_file = './SimHei.ttf'
if os.path.exists(font_file):
    zh_font = fm.FontProperties(fname=font_file)
    plt.rcParams['font.sans-serif'] = ['SimHei']
    fm.fontManager.addfont(font_file)
    print(f'字体加载成功: {font_file}')
else:
    zh_font = None
    print(f'缺少字体文件: {font_file}')

sns.set_theme(style='whitegrid', palette='muted')
plt.rcParams.update({
    'axes.unicode_minus': False,
    'figure.dpi': 130,
    'figure.figsize': [11, 6],
    'font.size': 10
})
print('环境初始化完成')
复制代码
字体加载成功: ./SimHei.ttf
环境初始化完成

1.2 配置LLM API

初始化通义千问API客户端,用于后续的智能画像生成和RAG推荐报告生成。如果未配置API Key,LLM模块将使用降级模式。

python 复制代码
from openai import OpenAI

DASHSCOPE_KEY = os.environ.get('DASHSCOPE_API_KEY', '')
if DASHSCOPE_KEY:
    llm_client = OpenAI(
        api_key=DASHSCOPE_KEY,
        base_url='https://dashscope.aliyuncs.com/compatible-mode/v1'
    )
    MODEL_ID = 'qwen3.7-max'
    print(f'LLM就绪: {MODEL_ID}')
else:
    llm_client = None
    print('[警告] 未配置API Key, LLM模块将使用降级模式')
复制代码
LLM就绪: qwen3.7-max

1.3 加载数据

从"数智教育数据集"中读取7张CSV数据表,包括教师表、学生档案表、考勤记录表、考勤类型字典表、考试成绩表、考试类型字典表和消费流水表。

python 复制代码
DATA_DIR = './数智教育数据集'

df_teacher  = pd.read_csv(f'{DATA_DIR}/1_teacher.csv')
df_student  = pd.read_csv(f'{DATA_DIR}/2_student_info.csv')
df_kaoqin   = pd.read_csv(f'{DATA_DIR}/3_kaoqin.csv')
df_kq_type  = pd.read_csv(f'{DATA_DIR}/4_kaoqintype.csv', encoding='gbk', sep='\t')
df_chengji  = pd.read_csv(f'{DATA_DIR}/5_chengji.csv')
df_exam     = pd.read_csv(f'{DATA_DIR}/6_exam_type.csv')
df_consume  = pd.read_csv(f'{DATA_DIR}/7_consumption.csv')

data_tables = {
    '教师表': df_teacher, '学生档案': df_student,
    '考勤记录': df_kaoqin, '考勤类型': df_kq_type,
    '考试成绩': df_chengji, '考试类型': df_exam,
    '消费流水': df_consume
}

print(f'{"数据表":>8s} | {"行数":>8s} | {"列数":>4s}')
print('-' * 30)
for tbl_name, tbl_df in data_tables.items():
    print(f'{tbl_name:>8s} | {tbl_df.shape[0]:>8,d} | {tbl_df.shape[1]:>4d}')

print('\n=== 缺失率概览 ===')
for tbl_name, tbl_df in data_tables.items():
    miss = tbl_df.isnull().mean().mean() * 100
    if miss > 0.5:
        print(f'  {tbl_name}: 整体缺失 {miss:.1f}%')
复制代码
     数据表 |       行数 |   列数
------------------------------
     教师表 |    3,088 |    8
    学生档案 |    1,765 |   14
    考勤记录 |   23,630 |   10
    考勤类型 |       15 |    4
    考试成绩 |  471,686 |   13
    考试类型 |       21 |    2
    消费流水 |  463,904 |    5

=== 缺失率概览 ===
  学生档案: 整体缺失 16.4%
  考试成绩: 整体缺失 11.4%

1.4 数据质量诊断

对各数据表的规模、缺失率、字段分布进行系统性检查,识别数据质量问题(如成绩字段的异常值、Z-Score的大面积缺失等),为后续的数据清洗提供依据。数据质量是画像构建可靠性的基础。

python 复制代码
# 数据质量深入诊断
df_chengji['score'] = pd.to_numeric(df_chengji['mes_Score'], errors='coerce')
df_chengji.loc[df_chengji['score'] < 0, 'score'] = np.nan
df_chengji['z_val'] = pd.to_numeric(df_chengji['mes_Z_Score'], errors='coerce')
df_chengji['t_val'] = pd.to_numeric(df_chengji['mes_T_Score'], errors='coerce')

score_valid = df_chengji['score'].notna().sum()
score_invalid = df_chengji['score'].isna().sum()

df_consume['amount'] = df_consume['MonDeal'].abs()
df_consume['ts'] = pd.to_datetime(df_consume['DealTime'])
df_kaoqin['dt'] = pd.to_datetime(df_kaoqin['DataDateTime'], errors='coerce')

print(f'成绩: 有效{score_valid:,} / 缺失{score_invalid:,} (缺失率{score_invalid/(score_valid+score_invalid)*100:.1f}%)')
print(f'消费: {len(df_consume):,}条, 均值{df_consume["amount"].mean():.2f}元')
print(f'考勤: {len(df_kaoqin):,}条')

fig, axes = plt.subplots(1, 2, figsize=(15, 5))

ax = axes[0]
col_miss = df_chengji.isnull().mean().sort_values(ascending=False)
col_miss = col_miss[col_miss > 0]
if len(col_miss) > 0:
    col_miss.plot.barh(ax=ax, color='#e15759', edgecolor='white')
    ax.set_xlabel('缺失比例', fontproperties=zh_font)
    ax.set_title('成绩表各字段缺失率', fontproperties=zh_font, fontsize=13)
    ax.set_yticklabels(ax.get_yticklabels(), fontproperties=zh_font)

ax = axes[1]
valid_scores = df_chengji['score'].dropna()
ax.hist(valid_scores, bins=50, color='#4e79a7', edgecolor='white', alpha=0.85)
ax.axvline(valid_scores.mean(), color='red', ls='--', lw=1.5, label=f'均值={valid_scores.mean():.1f}')
ax.axvline(valid_scores.median(), color='orange', ls='--', lw=1.5, label=f'中位数={valid_scores.median():.1f}')
ax.set_xlabel('分数', fontproperties=zh_font)
ax.set_ylabel('频次', fontproperties=zh_font)
ax.set_title('全体成绩分布', fontproperties=zh_font, fontsize=13)
ax.legend(prop=zh_font)

plt.tight_layout()
os.makedirs('./output', exist_ok=True)
plt.savefig('./output/ch1_quality.png', bbox_inches='tight')
plt.show()
复制代码
成绩: 有效422,705 / 缺失48,981 (缺失率10.4%)
消费: 463,904条, 均值8.37元
考勤: 23,630条

1.5 数据质量小结

从上面的分析可以看出几个关键信息:成绩表的mes_T_Score字段缺失率较高(约40%),在后续特征工程中需要注意处理方式;成绩分布呈现轻微左偏(高分段人数多于低分段),均值和中位数基本接近,说明没有严重的系统性偏差;消费数据量充足(46万+条),时间戳字段完整,适合进行消费行为分析。

这些数据质量特征直接影响了我们的特征工程策略:对于缺失率高的字段(如T-Score),我们选择不直接纳入特征矩阵,而是使用缺失率低的字段(原始分数、Z-Score)进行计算。

二、特征空间构建与降维

原始数据经过质量诊断后,下一步是将分散在多张表中的信息整合为统一的"学生特征向量"。本章的核心思路是:从四个维度提取量化特征,再通过PCA降维为后续的聚类分析提供低维、去相关的特征空间。

特征提取的四个维度

  • 学业表现维度: 从考试成绩表中聚合每个学生的均分、标准差、极差、考试次数、Z-Score均值等统计量,并计算变异系数(CV = 标准差/均值)来衡量成绩的相对波动性。
  • 学科结构维度: 构建学生×学科的成绩透视矩阵,并区分理科(数学、物理、化学、生物)和文科(语文、英语、政治、历史、地理)两大类别,计算文理均分差异来反映学科偏好。
  • 行为规范维度: 从考勤数据中统计每个学生的异常次数(迟到、早退、旷课等),作为自律性的代理指标。
  • 消费模式维度: 从消费流水中提取日均消费金额、消费波动性(变异系数)、消费活跃天数等特征,反映学生的生活规律和经济状况。

PCA降维的必要性

上述四个维度共提取了16个数值特征,但特征之间存在显著的相关性(例如均分与Z-Score高度正相关、消费总额与消费次数几乎等价)。如果直接在高维空间进行聚类,相关特征会导致某些维度被重复加权,影响聚类质量。PCA通过正交变换将原始特征映射到不相关的主成分空间,我们选取前3个主成分(累计方差解释率达到阈值即可)作为聚类的输入特征。

2.1 学业表现与行为消费特征提取

从考试成绩表中聚合每个学生的均分、标准差、极差、考试次数、Z-Score均值等统计量,并计算变异系数(CV = 标准差/均值)来衡量成绩的相对波动性。同时构建学生×学科的成绩透视矩阵,区分理科和文科两大类别,计算文理均分差异来反映学科偏好。

行为规范维度从考勤数据中统计每个学生的异常次数(迟到、早退、旷课等),作为自律性的代理指标。消费模式维度从消费流水中提取日均消费金额、消费波动性、消费活跃天数等特征,反映学生的生活规律和经济状况。

python 复制代码
# ==== 学业聚合特征 ====
g_agg = df_chengji.groupby('mes_StudentID').agg(
    mean_score=('score', 'mean'),
    std_score=('score', 'std'),
    max_score=('score', 'max'),
    min_score=('score', 'min'),
    exam_cnt=('score', 'count'),
    z_mean=('z_val', 'mean')
).reset_index()
g_agg.columns = ['sid', 'mean_score', 'std_score', 'max_score', 'min_score', 'exam_cnt', 'z_mean']
g_agg['score_range'] = g_agg['max_score'] - g_agg['min_score']
g_agg['cv'] = g_agg['std_score'] / g_agg['mean_score'].clip(lower=1)

# 学科透视表
sub_pivot = df_chengji.pivot_table(
    index='mes_StudentID', columns='mes_sub_name', values='score', aggfunc='mean'
).reset_index()
sub_pivot.columns = ['sid'] + [f'sub_{c}' for c in sub_pivot.columns[1:]]

# 文理分科
sci_subs = [c for c in sub_pivot.columns if any(k in c for k in ['数学','物理','化学','生物'])]
art_subs = [c for c in sub_pivot.columns if any(k in c for k in ['语文','英语','政治','历史','地理'])]
sub_pivot['sci_avg'] = sub_pivot[sci_subs].mean(axis=1)
sub_pivot['art_avg'] = sub_pivot[art_subs].mean(axis=1)
sub_pivot['sci_art_gap'] = sub_pivot['sci_avg'] - sub_pivot['art_avg']

print(f'学业聚合: {g_agg.shape[0]}人 × {g_agg.shape[1]}指标')
print(f'学科矩阵: {sub_pivot.shape[0]}人 × {len(sci_subs)+len(art_subs)}学科(文理)')

# ==== 考勤特征 ====
att_agg = df_kaoqin.groupby('bf_studentID').size().reset_index(name='att_violations')
att_agg.columns = ['sid', 'att_violations']

# ==== 消费特征 ====
sp_agg = df_consume.groupby('bf_StudentID').agg(
    sp_total=('amount', 'sum'),
    sp_mean=('amount', 'mean'),
    sp_std=('amount', 'std'),
    sp_max=('amount', 'max'),
    sp_cnt=('amount', 'count')
).reset_index()
sp_agg.columns = ['sid', 'sp_total', 'sp_mean', 'sp_std', 'sp_max', 'sp_cnt']
sp_agg['sp_cv'] = sp_agg['sp_std'] / sp_agg['sp_mean'].clip(lower=0.1)

sp_days = df_consume.groupby('bf_StudentID')['ts'].apply(
    lambda x: x.dt.date.nunique()
).reset_index(name='sp_days')
sp_days.columns = ['sid', 'sp_days']
sp_agg = sp_agg.merge(sp_days, on='sid', how='left')

print(f'考勤: {att_agg.shape[0]}人有记录')
print(f'消费: {sp_agg.shape[0]}人有记录')
复制代码
学业聚合: 3869人 × 9指标
学科矩阵: 3862人 × 9学科(文理)
考勤: 3058人有记录
消费: 1730人有记录

2.2 特征合并与PCA降维

将四个维度的特征合并为统一的"学生特征向量",通过StandardScaler标准化后进行PCA降维。选取前3个主成分作为聚类的输入特征,并通过碎石图和相关性矩阵验证降维效果。

python 复制代码
# ==== 合并主表 ====
master = df_student[['bf_StudentID', 'bf_Name', 'bf_sex', 'cla_Name', 'cla_id']].copy()
master.columns = ['sid', 'name', 'gender', 'class_name', 'class_id']

master = master.merge(g_agg, on='sid', how='left')
master = master.merge(sub_pivot.drop(columns=['sci_avg','art_avg','sci_art_gap'], errors='ignore'),
                      on='sid', how='left')
master = master.merge(sub_pivot[['sid','sci_avg','art_avg','sci_art_gap']], on='sid', how='left')
master = master.merge(att_agg, on='sid', how='left')
master = master.merge(sp_agg, on='sid', how='left')
master['att_violations'] = master['att_violations'].fillna(0).astype(int)

num_cols = ['mean_score','std_score','max_score','min_score','exam_cnt','z_mean',
            'score_range','cv','sci_avg','art_avg','sci_art_gap',
            'att_violations','sp_mean','sp_std','sp_cv','sp_days']
num_cols = [c for c in num_cols if c in master.columns]

print(f'主表: {master.shape[0]}人 × {master.shape[1]}列')
print(f'数值特征: {len(num_cols)}个')
print(f'覆盖率: 学业{master["mean_score"].notna().mean()*100:.0f}%, '
      f'消费{master["sp_mean"].notna().mean()*100:.0f}%')

# ==== PCA降维 ====
pca_df = master[num_cols].dropna()
scaler = StandardScaler()
X_scaled = scaler.fit_transform(pca_df)

pca_full = PCA()
pca_full.fit(X_scaled)
cumvar = np.cumsum(pca_full.explained_variance_ratio_)

pca3 = PCA(n_components=3)
X_pca3 = pca3.fit_transform(X_scaled)
pca_df['pc1'] = X_pca3[:, 0]
pca_df['pc2'] = X_pca3[:, 1]
pca_df['pc3'] = X_pca3[:, 2]

print(f'\nPCA前3主成分累计方差解释率: {cumvar[2]*100:.1f}%')
for i in range(3):
    print(f'  PC{i+1}: {pca_full.explained_variance_ratio_[i]*100:.1f}%')

# ---- 可视化: 碎石图 + 相关性矩阵 ----
fig, axes = plt.subplots(1, 2, figsize=(15, 5))

ax = axes[0]
n_comp = min(10, len(pca_full.explained_variance_ratio_))
ax.bar(range(1, n_comp+1), pca_full.explained_variance_ratio_[:n_comp]*100,
       color='#4e79a7', edgecolor='white', label='单成分')
ax.plot(range(1, n_comp+1), cumvar[:n_comp]*100, 'o-', color='#e15759', label='累计')
ax.axhline(80, color='gray', ls=':', lw=1)
ax.set_xlabel('主成分序号', fontproperties=zh_font)
ax.set_ylabel('方差解释率(%)', fontproperties=zh_font)
ax.set_title('PCA碎石图', fontproperties=zh_font, fontsize=13)
ax.legend(prop=zh_font)
ax.set_xticks(range(1, n_comp+1))

ax = axes[1]
corr = pd.DataFrame(X_scaled, columns=num_cols).corr()
mask = np.triu(np.ones_like(corr, dtype=bool), k=1)
sns.heatmap(corr, mask=mask, cmap='RdBu_r', center=0,
            annot=True, fmt='.1f', square=True, ax=ax,
            xticklabels=[c.replace('_','\n') for c in num_cols],
            yticklabels=[c.replace('_','\n') for c in num_cols],
            annot_kws={'size': 7})
ax.set_title('特征相关性矩阵', fontproperties=zh_font, fontsize=13)

plt.tight_layout()
plt.savefig('./output/ch2_pca_corr.png', bbox_inches='tight')
plt.show()
复制代码
主表: 1765人 × 40列
数值特征: 16个
覆盖率: 学业89%, 消费98%

PCA前3主成分累计方差解释率: 62.9%
  PC1: 28.7%
  PC2: 22.5%
  PC3: 11.6%

三、聚类驱动的学生分群

为什么选择聚类而非规则分箱?

传统的学生分层方法通常基于单一指标的人工分箱,例如按Z-Score将学生划分为"拔尖/中上/中等/中下/薄弱"五个等级。这种做法的核心缺陷在于:

  1. 维度割裂: 每个维度独立分箱,无法发现跨维度的组合模式(如"高均分但高波动"群体)
  2. 阈值主观: 分箱边界(如Z≥1.0为拔尖)依赖专家经验,缺乏数据支撑
  3. 粒度粗糙: 通常只产生3~5个离散标签,大量个体差异被掩盖

相比之下,K-Means聚类是一种无监督学习方法,能够在多维特征空间中自动发现自然的群体结构。我们的策略是:先在PCA降维后的3维主成分空间中运行K-Means,通过肘部法则 (Elbow Method)观察惯性随K值的衰减曲线,同时计算轮廓系数(Silhouette Score)评估聚类内部紧密度和聚类间分离度,两者结合确定最优聚类数。

聚类完成后,我们对每个群集进行特征画像:计算各群集在所有原始特征维度上的均值,生成平行坐标图直观展示各群集的特征轮廓,最终为每个群集撰写结构化的文字描述。

3.1 肘部法则与轮廓系数确定最优K值

在PCA降维后的3维主成分空间中运行K-Means,通过肘部法则(Elbow Method)观察惯性随K值的衰减曲线,同时计算轮廓系数(Silhouette Score)评估聚类内部紧密度和聚类间分离度,两者结合确定最优聚类数。

python 复制代码
# ==== 肘部法则 + 轮廓系数 ====
K_range = range(2, 11)
inertias = []
sil_scores = []

for k in K_range:
    km = KMeans(n_clusters=k, n_init=10, random_state=42, max_iter=300)
    labels = km.fit_predict(X_pca3)
    inertias.append(km.inertia_)
    sil_scores.append(silhouette_score(X_pca3, labels))

best_k = list(K_range)[np.argmax(sil_scores)]
print(f'最优K={best_k} (轮廓系数={max(sil_scores):.3f})')

# ==== 最终聚类 ====
K_final = best_k
km_final = KMeans(n_clusters=K_final, n_init=20, random_state=42, max_iter=500)
pca_df['cluster'] = km_final.fit_predict(X_pca3)

master = master.merge(pca_df[['cluster']], left_index=True, right_index=True, how='left')

print(f'\n=== 聚类结果(K={K_final}) ===')
for c in sorted(pca_df['cluster'].unique()):
    cnt = (pca_df['cluster'] == c).sum()
    print(f'  群集{c}: {cnt}人 ({cnt/len(pca_df)*100:.1f}%)')

# ---- 可视化: 肘部 + 轮廓 + 2D散点 ----
fig, axes = plt.subplots(1, 3, figsize=(18, 5))

ax = axes[0]
ax.plot(list(K_range), inertias, 'o-', color='#4e79a7', lw=2)
ax.axvline(K_final, color='red', ls='--', lw=1.5, label=f'K={K_final}')
ax.set_xlabel('聚类数K', fontproperties=zh_font)
ax.set_ylabel('惯性(组内平方和)', fontproperties=zh_font)
ax.set_title('肘部法则', fontproperties=zh_font, fontsize=13)
ax.legend(prop=zh_font)

ax = axes[1]
ax.plot(list(K_range), sil_scores, 's-', color='#59a14f', lw=2)
ax.axvline(K_final, color='red', ls='--', lw=1.5, label=f'K={K_final}')
ax.set_xlabel('聚类数K', fontproperties=zh_font)
ax.set_ylabel('轮廓系数', fontproperties=zh_font)
ax.set_title('轮廓系数曲线', fontproperties=zh_font, fontsize=13)
ax.legend(prop=zh_font)

ax = axes[2]
cluster_colors = ['#4e79a7','#e15759','#76b7b2','#f28e2b','#59a14f',
                  '#edc948','#b07aa1','#ff9da7','#9c755f','#bab0ac']
for c in sorted(pca_df['cluster'].unique()):
    mask = pca_df['cluster'] == c
    ax.scatter(pca_df.loc[mask, 'pc1'], pca_df.loc[mask, 'pc2'],
               c=cluster_colors[c % len(cluster_colors)], s=25, alpha=0.6,
               edgecolors='white', linewidth=0.3, label=f'群集{c}')
ax.set_xlabel('PC1', fontproperties=zh_font)
ax.set_ylabel('PC2', fontproperties=zh_font)
ax.set_title(f'PCA 2D聚类散点(K={K_final})', fontproperties=zh_font, fontsize=13)
ax.legend(prop=zh_font, fontsize=9)

plt.tight_layout()
plt.savefig('./output/ch3_clustering.png', bbox_inches='tight')
plt.show()
复制代码
最优K=2 (轮廓系数=0.443)

=== 聚类结果(K=2) ===
  群集0: 449人 (29.1%)
  群集1: 1094人 (70.9%)

3.2 聚类结果解读

通过平行坐标图,我们可以清晰地观察到各群集的特征差异。例如,某个群集可能在"均分"和"Z值"上显著高于其他群集(学霸群体),但在"考勤异常"上也偏高(高压环境下的出勤波动);另一个群集可能均分中等但"变异系数"极低(发挥极其稳定的中等生)。

这种多维度的群体画像比单一指标的标签分箱包含了更丰富的信息:它不仅告诉我们"这个群体成绩好",还告诉我们"这个群体成绩好但波动大、消费低"------这些跨维度的组合模式正是聚类方法的核心价值所在。

需要注意的是,K-Means的聚类结果对初始中心点敏感,我们通过设置n_init=20(运行20次取最优)来缓解这一问题。同时,轮廓系数提供了聚类质量的定量评估,值越接近1表示聚类内样本越紧密、不同聚类之间越分离。

3.3 各群集特征画像

计算各群集在所有原始特征维度上的均值,生成平行坐标图直观展示各群集的特征轮廓,并为每个群集撰写结构化的文字描述。

python 复制代码
# ==== 聚类画像: 各群集特征统计 ====
profile_cols = ['mean_score','std_score','z_mean','cv','sci_avg','art_avg',
                'sci_art_gap','att_violations','sp_mean','sp_days']
profile_cols = [c for c in profile_cols if c in master.columns]

cluster_stats = master.groupby('cluster')[profile_cols].mean()
cluster_z = (cluster_stats - cluster_stats.mean()) / cluster_stats.std().clip(lower=0.01)

print('=== 各群集核心指标均值 ===')
display_cols = ['mean_score','z_mean','cv','sci_art_gap','att_violations','sp_mean']
display_cols = [c for c in display_cols if c in cluster_stats.columns]
print(cluster_stats[display_cols].round(2).to_string())

# ---- 平行坐标图 ----
fig, ax = plt.subplots(figsize=(14, 6))
pd_coords = cluster_z[display_cols].copy()
pd_coords['cluster'] = pd_coords.index

for c in sorted(pd_coords['cluster'].unique()):
    row = pd_coords[pd_coords['cluster'] == c].iloc[0]
    vals = [row[col] for col in display_cols]
    color = cluster_colors[int(c) % len(cluster_colors)]
    ax.plot(range(len(display_cols)), vals, 'o-', color=color, lw=2.5,
            markersize=8, label=f'群集{c}')

ax.set_xticks(range(len(display_cols)))
ax.set_xticklabels([c.replace('_','\n') for c in display_cols], fontproperties=zh_font, fontsize=9)
ax.set_ylabel('标准化值', fontproperties=zh_font)
ax.set_title('各群集特征平行坐标图', fontproperties=zh_font, fontsize=14)
ax.legend(prop=zh_font, loc='upper right')
ax.axhline(0, color='gray', ls=':', lw=0.8)
plt.tight_layout()
plt.savefig('./output/ch3_parallel.png', bbox_inches='tight')
plt.show()

# ==== 聚类描述 ====
cluster_desc = {}
for c in sorted(master['cluster'].dropna().unique()):
    c = int(c)
    grp = master[master['cluster'] == c]
    parts = [f'群集{c}({len(grp)}人)']
    parts.append(f'均分{grp["mean_score"].mean():.1f}')
    z = grp['z_mean'].mean()
    parts.append(f'Z值{z:+.2f}')
    gap = grp['sci_art_gap'].mean() if 'sci_art_gap' in grp else 0
    if pd.notna(gap) and abs(gap) > 3:
        parts.append('偏理' if gap > 0 else '偏文')
    att = grp['att_violations'].mean()
    parts.append(f'考勤异常{att:.1f}次')
    sp = grp['sp_mean'].mean()
    if pd.notna(sp):
        parts.append(f'日均消费{sp:.1f}元')
    cluster_desc[c] = ','.join(parts)
    print(f'  {cluster_desc[c]}')
复制代码
=== 各群集核心指标均值 ===
         mean_score  z_mean    cv  sci_art_gap  att_violations  sp_mean
cluster                                                                
0.0           62.25    0.02  0.49        -3.33            8.12     9.45
1.0           53.28   -0.01  0.62         1.07            2.85     8.43
复制代码
  群集0(449人),均分62.3,Z值+0.02,偏文,考勤异常8.1次,日均消费9.5元
  群集1(1094人),均分53.3,Z值-0.01,考勤异常2.9次,日均消费8.4元

四、学业轨迹时序分析

静态画像的盲区

前面几章构建的画像------无论是规则标签还是聚类标签------本质上都是一种"快照式"的静态描述:它们反映的是学生在整个观测期内的平均水平,但完全忽略了成绩的时间演变规律

考虑两个均分相同的学生:一个持续进步、一个逐步退步。在静态画像中,他们的标签完全一致;但在教育实践中,这两个学生需要的干预策略截然不同。因此,引入时序分析是画像体系从"描述性"走向"诊断性"的关键一步。

方法设计

本章采用两个互补的分析视角:

  1. Mann-Kendall趋势检验: 这是一种非参数的趋势检测方法,不要求数据满足正态分布假设,非常适合成绩这种偏态分布的场景。它对每个学生的学期均分序列计算趋势方向和显著性p值,将学生分为"显著上升""显著下降""基本平稳""轻微波动"四类。我们设定至少4个学期的数据才进行趋势检验,以确保统计结论的可靠性。

  2. 学业层级迁移矩阵: 除了全局趋势,我们还关注学生在相邻学期之间的层级流动。将每个学期内的学生按成绩四分位数划分为"高/中高/中低/低"四个层级,然后计算相邻学期间的迁移概率矩阵。对角线值高表示层级稳定,非对角线值揭示进步或退步的流动方向和规模。

这两个分析共同为画像增加了"成长性"维度,使后续的教育干预能够区分"正在进步的学生"和"正在下滑的学生"。

4.1 Mann-Kendall趋势检验

将成绩数据按学期聚合,构建每个学生的学期均分时序序列。对每个学生的学期均分序列计算趋势方向和显著性p值,将学生分为"显著上升""显著下降""基本平稳""轻微波动"四类。设定至少4个学期的数据才进行趋势检验,以确保统计结论的可靠性。

python 复制代码
# ==== 构建学期级成绩序列 ====
df_chengji['exam_date'] = pd.to_datetime(df_chengji['exam_sdate'], errors='coerce')
df_chengji['semester'] = df_chengji['exam_date'].dt.to_period('2Q').astype(str)
df_chengji_sorted = df_chengji.dropna(subset=['exam_date','score','semester']).sort_values('exam_date')
sem_sub = df_chengji.groupby(['mes_StudentID','mes_sub_name','semester'])['score'].mean().reset_index()
sem_sub.columns = ['sid','subject','semester','sem_score']

# Mann-Kendall趋势检验
student_sem = df_chengji_sorted.groupby(['mes_StudentID','semester'])['score'].mean().reset_index()
student_sem.columns = ['sid','semester','sem_avg']

trend_results = {}
for sid, grp in student_sem.groupby('sid'):
    if len(grp) >= 4:
        scores = grp.sort_values('semester')['sem_avg'].values
        try:
            mk = mannkendall(scores)
            trend_results[sid] = {
                'trend': mk.trend, 'p_value': mk.p,
                'slope': mk.slope, 'sem_count': len(grp)
            }
        except:
            pass

trend_df = pd.DataFrame(trend_results).T.reset_index()
trend_df.columns = ['sid','trend','p_value','slope','sem_count']

def classify_trend(row):
    if row['p_value'] < 0.1:
        if row['slope'] > 0.5: return '显著上升'
        elif row['slope'] < -0.5: return '显著下降'
    if abs(row['slope']) <= 0.5: return '基本平稳'
    return '轻微波动'

trend_df['trend_label'] = trend_df.apply(classify_trend, axis=1)
master = master.merge(trend_df[['sid','trend','slope','trend_label']], on='sid', how='left')

print(f'趋势分析: {len(trend_df)}名学生(≥4学期)')
print('\n=== 趋势类型分布 ===')
for lbl, cnt in trend_df['trend_label'].value_counts().items():
    print(f'  {lbl}: {cnt}人 ({cnt/len(trend_df)*100:.1f}%)')

# ---- 可视化 ----
fig, axes = plt.subplots(1, 2, figsize=(15, 5))

ax = axes[0]
ax.hist(trend_df['slope'].dropna(), bins=40, color='#76b7b2', edgecolor='white', alpha=0.85)
ax.axvline(0, color='red', ls='--', lw=1.5)
ax.set_xlabel('Mann-Kendall斜率(每学期分数变化)', fontproperties=zh_font)
ax.set_ylabel('学生人数', fontproperties=zh_font)
ax.set_title('学业趋势斜率分布', fontproperties=zh_font, fontsize=13)

ax = axes[1]
trend_df['slope'] = pd.to_numeric(trend_df['slope'], errors='coerce')
up_students = trend_df[trend_df['trend_label']=='显著上升'].nlargest(3, 'slope')['sid'].tolist()
dn_students = trend_df[trend_df['trend_label']=='显著下降'].nsmallest(3, 'slope')['sid'].tolist()

for sid in up_students:
    data = student_sem[student_sem['sid']==sid].sort_values('semester')
    if len(data) >= 4:
        ax.plot(range(len(data)), data['sem_avg'].values, 'o-', color='#59a14f', alpha=0.7, lw=1.5)

for sid in dn_students:
    data = student_sem[student_sem['sid']==sid].sort_values('semester')
    if len(data) >= 4:
        ax.plot(range(len(data)), data['sem_avg'].values, 'o-', color='#e15759', alpha=0.7, lw=1.5)

ax.set_xlabel('学期序号', fontproperties=zh_font)
ax.set_ylabel('学期均分', fontproperties=zh_font)
ax.set_title('典型上升(绿)/下降(红)轨迹', fontproperties=zh_font, fontsize=13)
from matplotlib.lines import Line2D
legend_elements = [Line2D([0],[0],color='#59a14f',lw=2,label='上升趋势'),
                   Line2D([0],[0],color='#e15759',lw=2,label='下降趋势')]
ax.legend(handles=legend_elements, prop=zh_font)

plt.tight_layout()
plt.savefig('./output/ch4_trend.png', bbox_inches='tight')
plt.show()
复制代码
趋势分析: 3321名学生(≥4学期)

=== 趋势类型分布 ===
  轻微波动: 2265人 (68.2%)
  显著上升: 615人 (18.5%)
  基本平稳: 423人 (12.7%)
  显著下降: 18人 (0.5%)

4.2 学业层级迁移矩阵

将每个学期内的学生按成绩四分位数划分为"高/中高/中低/低"四个层级,然后计算相邻学期间的迁移概率矩阵。对角线值高表示层级稳定,非对角线值揭示进步或退步的流动方向和规模。

python 复制代码
# ==== 学科间迁移分析 ====
semesters_avail = sorted(student_sem['semester'].unique())
recent_sems = semesters_avail[-4:] if len(semesters_avail) >= 4 else semesters_avail

sem_labels = {}
for sem in recent_sems:
    sem_data = student_sem[student_sem['semester'] == sem]
    if len(sem_data) < 20:
        continue
    quartiles = sem_data['sem_avg'].quantile([0.25, 0.5, 0.75])
    def assign_level(s):
        if s >= quartiles[0.75]: return 3
        elif s >= quartiles[0.5]: return 2
        elif s >= quartiles[0.25]: return 1
        else: return 0
    sem_labels[sem] = sem_data.set_index('sid')['sem_avg'].apply(assign_level)

trans_matrices = {}
for i in range(len(recent_sems)-1):
    s1, s2 = recent_sems[i], recent_sems[i+1]
    if s1 not in sem_labels or s2 not in sem_labels:
        continue
    common = sem_labels[s1].index.intersection(sem_labels[s2].index)
    if len(common) < 10:
        continue
    mat = pd.crosstab(sem_labels[s1][common], sem_labels[s2][common], normalize='index')
    trans_matrices[(s1, s2)] = mat

if trans_matrices:
    n_plots = len(trans_matrices)
    fig, axes = plt.subplots(1, n_plots, figsize=(6*n_plots, 5))
    if n_plots == 1:
        axes = [axes]
    level_names = ['低','中低','中高','高']
    for idx, ((s1,s2), mat) in enumerate(trans_matrices.items()):
        ax = axes[idx]
        sns.heatmap(mat, annot=True, fmt='.0%', cmap='YlGnBu',
                    xticklabels=level_names, yticklabels=level_names,
                    ax=ax, vmin=0, vmax=1, linewidths=0.5)
        ax.set_title(f'{s1} → {s2}\n学业层级迁移概率', fontproperties=zh_font, fontsize=11)
        ax.set_xlabel('后一学期', fontproperties=zh_font)
        ax.set_ylabel('前一学期', fontproperties=zh_font)
        ax.set_xticklabels(ax.get_xticklabels(), fontproperties=zh_font)
        ax.set_yticklabels(ax.get_yticklabels(), fontproperties=zh_font)
    plt.tight_layout()
    plt.savefig('./output/ch4_transition.png', bbox_inches='tight')
    plt.show()
else:
    print('学期数据不足,无法构建转移矩阵')

print('\n-> 对角线值越高,说明学生层级越稳定;非对角线值揭示进步/退步流动')

4.3 时序分析小结

Mann-Kendall趋势检验和迁移矩阵从两个互补的角度揭示了学生学业的动态变化。趋势检验告诉我们"谁在进步、谁在退步",迁移矩阵告诉我们"层级之间的流动有多频繁、主要流向哪个方向"。

一个重要的发现是:大多数学生的层级是相对稳定的(迁移矩阵对角线值通常在60%以上),但仍有相当比例的学生存在跨层级流动。这意味着教育干预是有窗口期的------对于正在下滑的学生,如果能在趋势确立的早期进行干预,效果将远好于问题积累后的补救。

在后续的LLM画像生成中,我们将趋势标签("显著上升""显著下降"等)作为输入信息之一,使LLM能够结合静态特征和动态趋势生成更全面的画像报告。

五、规则画像与LLM画像对比

对比实验设计

在前面的章节中,我们已经通过聚类分析获得了学生群体标签,通过时序分析获得了成长性标签。本章将回到画像构建的核心问题:如何为每个学生生成一份有价值的画像描述?

我们设计了两组画像方法进行对比实验:

方法A:传统规则画像

采用if-else规则引擎,将数值特征映射为离散标签。例如:

  • 学业维度:Z-Score ≥ 0.8 → "优秀",0 ≤ Z < 0.8 → "中等偏上",以此类推
  • 行为维度:考勤异常 ≤ 2次 → "规范",3~6次 → "需关注",> 6次 → "预警"

最终画像为标签拼接形式,如"学业:中等偏上 | 行为:规范"。

方法B:LLM智能画像

利用通义千问大语言模型,将学生的多维数据(含聚类标签和趋势信息)以自然语言形式输入,要求模型输出结构化的JSON画像报告,包括核心概述、学业分析、行为分析、风险标记、优势识别和干预建议。

评估策略

除了定性对比两种画像的可读性和信息量外,我们还引入了TF-IDF文本分析 来量化LLM画像的语义丰富度:对所有LLM画像文本进行TF-IDF向量化,提取高频关键词,验证其与教育领域的相关性;再通过t-SNE降维将画像文本映射到2维语义空间,观察不同群集的学生画像是否在语义上自然分离。

5.1 传统规则画像

采用if-else规则引擎,将数值特征映射为离散标签。学业维度按Z-Score划分为"优秀/中等偏上/中等偏下/困难"四级,行为维度按考勤异常次数划分为"规范/需关注/预警"三级。最终画像为标签拼接形式。

python 复制代码
# ==== 传统规则画像(简化版) ====
def rule_label(row):
    z = row.get('z_mean', np.nan)
    if pd.isna(z): return '无数据'
    if z >= 0.8: return '优秀'
    elif z >= 0: return '中等偏上'
    elif z >= -0.8: return '中等偏下'
    else: return '困难'

master['rule_tier'] = master.apply(rule_label, axis=1)

def rule_behavior(row):
    v = row.get('att_violations', 0)
    return '规范' if v <= 2 else '需关注' if v <= 6 else '预警'

master['rule_behavior'] = master.apply(rule_behavior, axis=1)

master['rule_profile'] = master.apply(
    lambda r: f"学业:{r['rule_tier']} | 行为:{r['rule_behavior']}", axis=1)

print('=== 规则标签分布 ===')
print(master['rule_tier'].value_counts().to_string())
print()
print(master['rule_behavior'].value_counts().to_string())

# ---- 标签分布可视化 ----
fig, axes = plt.subplots(1, 2, figsize=(13, 5))

ax = axes[0]
tier_order = ['困难','中等偏下','中等偏上','优秀','无数据']
tier_cnt = master['rule_tier'].value_counts().reindex(tier_order).dropna()
tier_cnt.plot.bar(ax=ax, color=['#e15759','#f28e2b','#76b7b2','#59a14f','#bab0ac'], edgecolor='white')
ax.set_title('规则方法: 学业标签分布', fontproperties=zh_font, fontsize=13)
ax.set_ylabel('人数', fontproperties=zh_font)
ax.set_xticklabels(ax.get_xticklabels(), fontproperties=zh_font, rotation=0)

ax = axes[1]
cross = pd.crosstab(master['cluster'], master['rule_tier'])
sns.heatmap(cross, annot=True, fmt='d', cmap='Blues', ax=ax)
ax.set_title('聚类标签 vs 规则标签 交叉热力图', fontproperties=zh_font, fontsize=13)
ax.set_xlabel('规则标签', fontproperties=zh_font)
ax.set_ylabel('聚类群集', fontproperties=zh_font)
ax.set_xticklabels(ax.get_xticklabels(), fontproperties=zh_font)
ax.set_yticklabels(ax.get_yticklabels(), fontproperties=zh_font)

plt.tight_layout()
plt.savefig('./output/ch5_rule_vs_cluster.png', bbox_inches='tight')
plt.show()

print('\n-> 聚类能发现规则方法遗漏的群体特征(如高均分但高波动群体)')
复制代码
=== 规则标签分布 ===
rule_tier
中等偏上    834
中等偏下    593
无数据     199
困难      109
优秀       30

rule_behavior
规范     1104
需关注     355
预警      306

5.2 LLM智能画像

利用通义千问大语言模型,将学生的多维数据(含聚类标签和趋势信息)以自然语言形式输入,要求模型输出结构化的JSON画像报告,包括核心概述、学业分析、行为分析、风险标记、优势识别和干预建议。

python 复制代码
# ==== LLM智能画像 ====
def build_data_text(row):
    parts = [f"学生{row['name']}, {row['gender']}, {row['class_name']}。"]
    if pd.notna(row.get('mean_score')):
        parts.append(f"均分{row['mean_score']:.1f}, Z值{row.get('z_mean',0):+.2f}, "
                     f"极差{row.get('score_range',0):.0f}, 变异系数{row.get('cv',0):.2f}。")
    if pd.notna(row.get('sci_avg')) and pd.notna(row.get('art_avg')):
        parts.append(f"理科均分{row['sci_avg']:.1f}, 文科均分{row['art_avg']:.1f}, "
                     f"文理差{row.get('sci_art_gap',0):+.1f}。")
    if pd.notna(row.get('trend_label')):
        parts.append(f"学业趋势: {row['trend_label']}(斜率{row.get('slope',0):+.2f})。")
    parts.append(f"考勤异常{int(row.get('att_violations',0))}次。")
    if pd.notna(row.get('sp_mean')):
        parts.append(f"日均消费{row['sp_mean']:.1f}元, 活跃{int(row.get('sp_days',0))}天。")
    if pd.notna(row.get('cluster')):
        parts.append(f"所属聚类: 群集{int(row['cluster'])}。")
    return ''.join(parts)

LLM_SYS = (
    "你是资深教育数据分析师。根据学生多维数据生成JSON画像。\n"
    "JSON结构:\n"
    '{\n'
    '  "summary": "80字以内核心画像概述",\n'
    '  "academic": "学业分析(含趋势解读)",\n'
    '  "behavior": "行为模式分析",\n'
    '  "risks": ["风险1","风险2"],\n'
    '  "strengths": ["优势1","优势2"],\n'
    '  "interventions": ["建议1","建议2"]\n'
    '}\n'
    "要求: 综合聚类标签和趋势信息进行关联推理,给出可操作建议。"
)

def call_llm(data_text):
    if llm_client is None:
        return {'summary':'降级模式','academic':'待分析','behavior':'待分析',
                'risks':[],'strengths':[],'interventions':[]}
    try:
        resp = llm_client.chat.completions.create(
            model=MODEL_ID,
            messages=[
                {'role':'system','content':LLM_SYS},
                {'role':'user','content':f'分析:\n{data_text}'}
            ],
            temperature=0.2,
            response_format={'type':'json_object'}
        )
        return json.loads(resp.choices[0].message.content)
    except Exception as e:
        return None

# 分层采样
sample_ids = []
for c in sorted(master['cluster'].dropna().unique()):
    pool = master[master['cluster'] == c]
    n = min(3, len(pool))
    sample_ids.extend(pool.sample(n, random_state=99).index.tolist())

print(f'采样{len(sample_ids)}名学生进行LLM画像...')

llm_portraits = {}
for i, idx in enumerate(sample_ids):
    row = master.loc[idx]
    txt = build_data_text(row)
    print(f'  [{i+1}/{len(sample_ids)}] {row["name"]}...', end=' ')
    result = call_llm(txt)
    if result:
        llm_portraits[idx] = result
        print('OK')
    else:
        llm_portraits[idx] = {'summary':'调用失败','academic':'N/A','behavior':'N/A',
                               'risks':[],'strengths':[],'interventions':[]}
        print('FAIL')
    if llm_client:
        time.sleep(0.4)

print(f'\n完成: {len(llm_portraits)}份画像')
复制代码
采样6名学生进行LLM画像...
  [1/6] 钱某某... OK
  [2/6] 姜某某... OK
  [3/6] 徐某某... OK
  [4/6] 陈某某... OK
  [5/6] 林某某... OK
  [6/6] 刘某某... OK

完成: 6份画像

5.3 TF-IDF语义分析与t-SNE可视化

对所有LLM画像文本进行TF-IDF向量化,提取高频关键词验证其与教育领域的相关性;再通过t-SNE降维将画像文本映射到2维语义空间,观察不同群集的学生画像是否在语义上自然分离。

python 复制代码
# ==== LLM画像展示 + TF-IDF语义分析 ====
print('=== LLM画像示例 ===')
for idx in list(llm_portraits.keys())[:4]:
    r = master.loc[idx]
    p = llm_portraits[idx]
    print(f'\n{"─"*55}')
    print(f'{r["name"]}({r["gender"]},{r["class_name"]}) | 聚类:群集{int(r.get("cluster",0))}')
    print(f'  概述: {p.get("summary","N/A")}')
    print(f'  学业: {p.get("academic","N/A")}')
    print(f'  行为: {p.get("behavior","N/A")}')
    print(f'  风险: {", ".join(p.get("risks",[]))}')
    print(f'  优势: {", ".join(p.get("strengths",[]))}')
    print(f'  建议: {", ".join(p.get("interventions",[]))}')

# ---- TF-IDF语义分析 ----
all_texts = []
all_names = []
for idx, p in llm_portraits.items():
    combined = ' '.join([
        p.get('summary',''), p.get('academic',''), p.get('behavior',''),
        ' '.join(p.get('risks',[])), ' '.join(p.get('strengths',[])),
        ' '.join(p.get('interventions',[]))
    ])
    all_texts.append(combined)
    all_names.append(master.loc[idx, 'name'])

tfidf = TfidfVectorizer(max_features=200)
tfidf_mat = tfidf.fit_transform(all_texts)

feature_names = tfidf.get_feature_names_out()
avg_tfidf = np.asarray(tfidf_mat.mean(axis=0)).flatten()
top_idx = avg_tfidf.argsort()[::-1][:20]

print(f'\n=== TF-IDF Top20关键词 ===')
for i in top_idx:
    if avg_tfidf[i] > 0:
        print(f'  {feature_names[i]}: {avg_tfidf[i]:.4f}')

# ---- t-SNE语义空间可视化 ----
if len(all_texts) >= 3:
    tsne = TSNE(n_components=2, random_state=42, perplexity=min(5, len(all_texts)-1))
    embed_2d = tsne.fit_transform(tfidf_mat.toarray())

    fig, axes = plt.subplots(1, 2, figsize=(15, 5))

    ax = axes[0]
    top_n = 15
    top_words = [feature_names[i] for i in top_idx[:top_n]]
    top_vals = [avg_tfidf[i] for i in top_idx[:top_n]]
    ax.barh(top_words[::-1], top_vals[::-1], color='#59a14f', edgecolor='white')
    ax.set_xlabel('平均TF-IDF权重', fontproperties=zh_font)
    ax.set_title('LLM画像TF-IDF关键词', fontproperties=zh_font, fontsize=13)
    ax.set_yticklabels(top_words[::-1], fontproperties=zh_font)

    ax = axes[1]
    for i, idx_key in enumerate(llm_portraits.keys()):
        c = master.loc[idx_key, 'cluster']
        color = cluster_colors[int(c) % len(cluster_colors)] if pd.notna(c) else 'gray'
        ax.scatter(embed_2d[i,0], embed_2d[i,1], c=color, s=100,
                   edgecolors='white', linewidth=1.5, zorder=5)
        ax.annotate(all_names[i], (embed_2d[i,0], embed_2d[i,1]),
                    fontsize=8, fontproperties=zh_font, xytext=(4,4), textcoords='offset points')
    ax.set_title('画像文本t-SNE语义空间', fontproperties=zh_font, fontsize=13)
    ax.set_xlabel('维度1', fontproperties=zh_font)
    ax.set_ylabel('维度2', fontproperties=zh_font)

    plt.tight_layout()
    plt.savefig('./output/ch5_tfidf_tsne.png', bbox_inches='tight')
    plt.show()
复制代码
=== LLM画像示例 ===

───────────────────────────────────────────────────────
钱某某(女,高三(10)) | 聚类:群集0
  概述: 该生成绩中下且波动大,文科略优,近期轻微下滑。在校时间长、纪律好但效率偏低;生活极度节俭。需重点关注学法指导与生活关怀。
  学业: 均分59.4(Z值-0.25)属中下水平,极差110与变异系数0.48凸显成绩极不稳定。文科(60.3)略优于理科(54.0)。趋势斜率-1.35表明成绩轻微下滑,高三复习期存在知识消化不良或学习瓶颈,呈现典型的'高耗时低产出'特征。
  行为: 活跃104天且考勤异常仅2次,表明在校时间长、纪律性强,具备吃苦精神。但日均消费仅11.1元,生活极度节俭,可能反映家庭经济压力或饮食不规律,需警惕因营养不足导致的高三备考精力透支。
  风险: 成绩持续下滑与高三备考焦虑风险, 极低消费引发的营养不良与精力透支风险
  优势: 在校活跃度高且考勤纪律良好, 文科具备相对优势与提分空间
  建议: 开展学情诊断与理科基础帮扶,优化学习方法以打破苦学无效困境, 私下排查低消费原因并提供隐性资助,加强心理疏导与备考精力管理

───────────────────────────────────────────────────────
姜某某(女,高三(08)) | 聚类:群集0
  概述: 成绩中等偏下且极不稳定,呈轻微下滑趋势;文科略优于理科。考勤异常较多,日均消费极低,需重点关注其身心健康、经济状况及学习状态。
  学业: 均分61.0处于中下游,极差125与变异系数0.48表明成绩极不稳定。文科(61.2)略优于理科(57.0)。斜率-2.80显示成绩呈轻微下滑趋势,高三复习期需警惕基础不牢导致的进一步滑坡。
  行为: 考勤异常12次反映纪律松懈或存在厌学、逃避心理。日均消费仅8元远低于正常水平,提示潜在的经济困难、过度节食或校外就餐问题。活跃98天表明在校时间长,但学习专注度与有效性存疑。
  风险: 成绩持续下滑与高考失利风险, 营养不良及潜在的心理或经济危机
  优势: 文科相对较好,具备提分潜力, 在校活跃度高,未脱离学校环境,便于及时干预
  建议: 立即开展谈心与家访,排查考勤异常原因并核实低消费情况,必要时提供经济资助或心理疏导, 制定"稳文补理"策略,强化理科基础训练,建立错题本以减小成绩波动,遏制下滑趋势

───────────────────────────────────────────────────────
徐某某(女,高三(09)) | 聚类:群集0
  概述: 该生属中上游文科偏好型,成绩较好但波动大且微降。生活节俭,偶有考勤异常,需重点关注理科短板与高三心理状态。
  学业: 成绩居年级中上游(Z值+0.79),文科优势明显(文理差-6.2)。但极差119、变异系数0.47显示成绩极不稳定。高三关键期呈轻微下滑趋势(斜率-1.34),理科薄弱(65.3分)是核心拖累,存在瓶颈期效应。
  行为: 日均消费10.1元处于低位,生活节俭但需防范饮食不规律。考勤异常3次,结合成绩下滑,折射出高三高压下可能出现阶段性倦怠、作息紊乱或心理焦虑,在校专注度偶有下降。
  风险: 理科短板与高变异系数(0.47)易导致大考发挥失常, 成绩下滑叠加考勤异常与低消费,潜藏心理焦虑或健康隐患
  优势: 文科基础扎实,具备显著的学科比较优势, 整体学业基本盘稳固(Z值+0.79),具备冲刺更高目标的潜力
  建议: 实施'稳文补理'策略,针对理科进行模块化专项训练以降低成绩波动, 班主任与心理教师联合介入,排查低消费与考勤异常原因,疏导高三备考压力

───────────────────────────────────────────────────────
陈某某(男,白-高二(05)) | 聚类:群集1
  概述: 该生为高二中等生,理科略优,成绩整体微升但单次波动极大。出勤纪律极佳,生活节俭。需重点解决成绩不稳定问题,并关注其心理状态。
  学业: 均分51.9(Z值+0.02)处于年级中游,理科(52.7)略优于文科(48.0)。整体趋势轻微上升(斜率+2.13),但极差112与变异系数0.64暴露出成绩极度不稳定,知识体系存在明显漏洞,受试题难度影响大。
  行为: 考勤0异常且活跃110天,表明该生纪律性强、在校参与度高,属于踏实守纪型学生。日均消费仅7元,生活极为节俭,性格可能偏内向,未见违纪或不良行为倾向。
  风险: 成绩高波动风险:极差112与高变异系数显示知识掌握不系统,大考易发挥失常。, 文科短板与隐性心理风险:文科均分偏低易拖累总分;极低消费与守纪特征可能伴随社交孤立或隐性压力。
  优势: 行为规范与自律性:考勤零异常,在校活跃度高,具备极强的规则意识和踏实的学习态度。, 理科潜力与微升趋势:理科表现优于文科,且学业整体呈轻微上升态势,具备进一步提分的基础。
  建议: 实施错题归因与基础加固:针对高极差现象开展深度试卷分析,精准定位并修补知识漏洞,提升考试稳定性。, 优化选科规划与综合关怀:结合理科优势指导选科,制定文科基础保底策略;关注其低消费背后的生活状况,鼓励参与集体活动以拓宽社交。

=== TF-IDF Top20关键词 ===
  z值: 0.0845
  生活极度节俭: 0.0742
  斜率: 0.0715
  日均消费极低: 0.0605
  79: 0.0527
  文理均衡: 0.0518
  略优于理科: 0.0499
  文科: 0.0499
  稳文补理: 0.0469
  策略: 0.0469
  在校活跃度高: 0.0465
  趋势斜率: 0.0458
  文理差: 0.0429
  生活节俭: 0.0427
  立即开展谈心与家访: 0.0309
  日均消费仅8元远低于正常水平: 0.0309
  极差125与变异系数0: 0.0309
  未脱离学校环境: 0.0309
  排查考勤异常原因并核实低消费情况: 0.0309
  提示潜在的经济困难: 0.0309

六、多策略推荐系统

推荐问题的定义

在教育场景下,个性化推荐的核心问题是:给定一个学生的历史成绩数据,预测他在尚未修读的学科上的表现,从而为学习资源的分配和教学干预的优先级提供数据支撑。

与电商推荐(预测用户对商品的评分)不同,教育推荐有其独特性:学科之间存在天然的关联结构(数学与物理强相关、语文与英语强相关),且推荐结果需要具有可解释性------教师和家长需要理解"为什么推荐这个学科"。

三种互补策略

本章实现了三种推荐策略,各有侧重:

策略1:Item-based协同过滤 --- 基于学科间的相似度进行推荐。与User-based CF(找相似学生)不同,Item-based CF计算的是学科之间的余弦相似度。它的直觉是:如果一个学生数学好,而数学与物理高度相似,那么他在物理上也可能表现好。这种方法的优势在于学科相似度矩阵相对稳定,不随学生数量变化而重新计算。

策略2:SVD矩阵分解 --- 将学生×学科评分矩阵分解为低秩近似,提取隐含的潜在因子(latent factors)。每个学科和每个学生都被映射到一个k维潜在空间中,通过内积重构缺失的评分。SVD能够捕获学科间非显式的关联模式(如"逻辑推理能力"这一隐含因子同时影响数学、物理和技术的成绩)。

策略3:LLM + 向量检索(RAG框架) --- 将LLM生成的学生画像存入ChromaDB向量数据库,面对推荐查询时先通过语义检索找到最相似的学生案例,再将这些案例作为上下文喂给LLM进行推理,生成可解释的推荐报告。这种检索增强生成(Retrieval-Augmented Generation, RAG)框架的优势在于:推荐结果带有语义解释和案例佐证,适合面向教师和家长的使用场景。

6.1 Item-based协同过滤

基于学科间的余弦相似度进行推荐。与User-based CF不同,Item-based CF计算的是学科之间的相似度。学科相似度矩阵相对稳定,不随学生数量变化而重新计算。

python 复制代码
# ==== Item-based协同过滤 ====
rating_mat = df_chengji.pivot_table(
    index='mes_StudentID', columns='mes_sub_name', values='score', aggfunc='mean')

print(f'评分矩阵: {rating_mat.shape[0]}学生 × {rating_mat.shape[1]}学科')
print(f'稀疏度: {rating_mat.isna().mean().mean()*100:.1f}%')

# 学科间相似度(Item-Item)
item_sim = pd.DataFrame(
    cosine_similarity(rating_mat.T.fillna(0)),
    index=rating_mat.columns, columns=rating_mat.columns)

def item_cf_predict(uid, target_sub, k=5):
    if uid not in rating_mat.index:
        return np.nan
    user_scores = rating_mat.loc[uid].dropna()
    known_subs = [s for s in user_scores.index if s != target_sub]
    if not known_subs:
        return np.nan
    sims = item_sim.loc[target_sub, known_subs].nlargest(k)
    num, den = 0, 0
    for sub, sim in sims.items():
        if sim > 0:
            num += sim * user_scores[sub]
            den += abs(sim)
    return num / den if den > 0 else np.nan

def item_cf_recommend(uid, n=3):
    if uid not in rating_mat.index:
        return []
    known = rating_mat.loc[uid].dropna().index.tolist()
    unknown = [s for s in rating_mat.columns if s not in known]
    preds = {}
    for sub in unknown:
        p = item_cf_predict(uid, sub)
        if pd.notna(p):
            preds[sub] = p
    return sorted(preds.items(), key=lambda x: x[1], reverse=True)[:n]

demo_uid = rating_mat.index[10]
recs = item_cf_recommend(demo_uid)
print(f'\n学生{demo_uid}的Item-CF推荐:')
for sub, pred in recs:
    print(f'  {sub}: 预测{pred:.1f}分')

# 学科相似度热力图
fig, ax = plt.subplots(figsize=(10, 8))
sns.heatmap(item_sim, annot=True, fmt='.2f', cmap='coolwarm',
            square=True, ax=ax, linewidths=0.5,
            xticklabels=item_sim.columns, yticklabels=item_sim.index)
ax.set_title('学科间相似度矩阵(Item-Item)', fontproperties=zh_font, fontsize=14)
ax.set_xticklabels(ax.get_xticklabels(), fontproperties=zh_font, rotation=45, ha='right')
ax.set_yticklabels(ax.get_yticklabels(), fontproperties=zh_font)
plt.tight_layout()
plt.savefig('./output/ch6_item_sim.png', bbox_inches='tight')
plt.show()
复制代码
评分矩阵: 3862学生 × 16学科
稀疏度: 23.1%

学生10853的Item-CF推荐:
  物理: 预测88.8分
  化学: 预测88.7分
  生物: 预测88.6分

6.2 SVD矩阵分解

将学生×学科评分矩阵分解为低秩近似,提取隐含的潜在因子(latent factors)。每个学科和每个学生都被映射到一个k维潜在空间中,通过内积重构缺失的评分。

python 复制代码
# ==== SVD矩阵分解 ====
global_mean = rating_mat.values[~np.isnan(rating_mat.values)].mean()
R_filled = np.nan_to_num(rating_mat.values, nan=global_mean)
R_mean = np.mean(R_filled, axis=1, keepdims=True)
R_centered = R_filled - R_mean

k_svd = 5
U, sigma, Vt = svds(R_centered.astype(float), k=k_svd)
sigma_diag = np.diag(sigma)

R_pred = U @ sigma_diag @ Vt + R_mean
R_pred_df = pd.DataFrame(R_pred, index=rating_mat.index, columns=rating_mat.columns)

mask = ~rating_mat.isna()
rmse = np.sqrt(((rating_mat.values[mask] - R_pred[mask.values])**2).mean())
print(f'SVD矩阵分解(k={k_svd}):')
print(f'  重构RMSE: {rmse:.2f}')
print(f'  潜在因子: {k_svd}维')

def svd_recommend(uid, n=3):
    if uid not in R_pred_df.index:
        return []
    known = rating_mat.loc[uid].dropna().index.tolist()
    preds = R_pred_df.loc[uid].drop(known, errors='ignore')
    return list(preds.nlargest(n).items())

demo_recs_svd = svd_recommend(demo_uid)
print(f'\n学生{demo_uid}的SVD推荐:')
for sub, score in demo_recs_svd:
    print(f'  {sub}: 预测{score:.1f}分')

# ---- 潜在因子可视化 ----
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

ax = axes[0]
factor_df = pd.DataFrame(Vt[:2].T, columns=['因子1','因子2'], index=rating_mat.columns)
for sub in factor_df.index:
    ax.scatter(factor_df.loc[sub,'因子1'], factor_df.loc[sub,'因子2'],
               s=120, zorder=5, edgecolors='white', linewidth=1.5)
    ax.annotate(sub, (factor_df.loc[sub,'因子1'], factor_df.loc[sub,'因子2']),
                fontsize=9, fontproperties=zh_font, xytext=(5,3), textcoords='offset points')
ax.set_xlabel('潜在因子1', fontproperties=zh_font)
ax.set_ylabel('潜在因子2', fontproperties=zh_font)
ax.set_title('学科潜在因子空间分布', fontproperties=zh_font, fontsize=13)

ax = axes[1]
ks = [2,3,4,5,6,7,8]
rmses = []
for k in ks:
    U_k, s_k, Vt_k = svds(R_centered.astype(float), k=k)
    R_k = U_k @ np.diag(s_k) @ Vt_k + R_mean
    rmse_k = np.sqrt(((rating_mat.values[mask] - R_k[mask.values])**2).mean())
    rmses.append(rmse_k)
ax.plot(ks, rmses, 'o-', color='#e15759', lw=2)
ax.set_xlabel('潜在因子数k', fontproperties=zh_font)
ax.set_ylabel('重构RMSE', fontproperties=zh_font)
ax.set_title('SVD不同维度的重构误差', fontproperties=zh_font, fontsize=13)

plt.tight_layout()
plt.savefig('./output/ch6_svd.png', bbox_inches='tight')
plt.show()
复制代码
SVD矩阵分解(k=5):
  重构RMSE: 4.42
  潜在因子: 5维

学生10853的SVD推荐:
  音乐: 预测75.3分
  美术: 预测68.8分
  技术: 预测68.5分

6.3 LLM+向量检索增强推荐(RAG框架)

将LLM生成的学生画像存入ChromaDB向量数据库,面对推荐查询时先通过语义检索找到最相似的学生案例,再将这些案例作为上下文喂给LLM进行推理,生成可解释的推荐报告。

python 复制代码
# ==== LLM + 向量检索增强推荐(RAG框架) ====
vdb = chromadb.Client()
portrait_collection = vdb.create_collection(
    name='portraits_v2',
    metadata={'hnsw:space': 'cosine'},
    get_or_create=True
)

doc_list, id_list, meta_list = [], [], []
for idx, portrait in llm_portraits.items():
    doc = portrait.get('summary','') + ' ' + portrait.get('academic','')
    if len(doc.strip()) > 10:
        r = master.loc[idx]
        doc_list.append(doc)
        id_list.append(str(idx))
        meta_list.append({
            'name': r['name'],
            'cluster': int(r['cluster']) if pd.notna(r.get('cluster')) else -1,
            'trend': str(r.get('trend_label','未知'))
        })

if doc_list:
    portrait_collection.add(documents=doc_list, ids=id_list, metadatas=meta_list)
    print(f'向量库: {len(doc_list)}份画像已索引')

def rag_recommend(query, n_retrieve=3):
    results = portrait_collection.query(query_texts=[query], n_results=n_retrieve)
    retrieved_docs = results['documents'][0]
    retrieved_metas = results['metadatas'][0]
    retrieved_dists = results['distances'][0]

    print(f'检索到{n_retrieve}份相关画像:')
    for i, (m, d) in enumerate(zip(retrieved_metas, retrieved_dists)):
        print(f'  [{i+1}] {m["name"]}(群集{m["cluster"]}, 趋势:{m["trend"]}, 相似度{1-d:.3f})')

    context = '\n'.join([f'案例{i+1}: {doc}' for i, doc in enumerate(retrieved_docs)])

    if llm_client is None:
        return {
            'retrieved': retrieved_metas,
            'analysis': '降级模式: 无LLM推理',
            'recommendations': ['参考相似案例的学习路径']
        }

    sys_prompt = (
        "你是教育推荐系统。根据检索到的学生案例,为查询目标生成推荐方案。\n"
        "输出JSON: { \"analysis\": \"综合分析\", \"recommendations\": [\"推荐1\",\"推荐2\",\"推荐3\"] }"
    )
    try:
        resp = llm_client.chat.completions.create(
            model=MODEL_ID,
            messages=[
                {'role':'system','content':sys_prompt},
                {'role':'user','content':f'查询: {query}\n\n检索案例:\n{context}'}
            ],
            temperature=0.3,
            response_format={'type':'json_object'}
        )
        result = json.loads(resp.choices[0].message.content)
        result['retrieved'] = retrieved_metas
        return result
    except:
        return {'retrieved': retrieved_metas, 'analysis':'LLM调用失败', 'recommendations':[]}

print('\n' + '='*55)
demo_query = '成绩波动大、偏科严重的高二学生'
rag_result = rag_recommend(demo_query)
print(f'\nRAG推荐分析: {rag_result.get("analysis","N/A")}')
print(f'推荐方案:')
for rec in rag_result.get('recommendations', []):
    print(f'  → {rec}')

# ---- 推荐系统流程图 ----
fig, ax = plt.subplots(figsize=(14, 6))
ax.set_xlim(0, 12)
ax.set_ylim(0, 5)
ax.axis('off')
ax.set_title('RAG增强推荐系统架构', fontproperties=zh_font, fontsize=15, fontweight='bold')

components = [
    (1.5, 3.5, '学生数据', '#4e79a7'),
    (1.5, 1.5, 'LLM画像', '#59a14f'),
    (4.5, 2.5, '向量数据库\n(Chroma)', '#e15759'),
    (7.5, 3.5, '相似案例\n检索', '#f28e2b'),
    (7.5, 1.5, 'Item-CF\n+SVD', '#76b7b2'),
    (10.5, 2.5, '融合推荐\n报告', '#b07aa1')
]
for x, y, text, color in components:
    rect = plt.Rectangle((x-1, y-0.6), 2, 1.2, facecolor=color, alpha=0.2,
                          edgecolor=color, linewidth=2, zorder=3)
    ax.add_patch(rect)
    ax.text(x, y, text, ha='center', va='center', fontproperties=zh_font,
            fontsize=10, fontweight='bold', zorder=4)

arrows = [(2.5,3.5,3.5,2.8),(2.5,1.5,3.5,2.2),(5.5,2.5,6.5,3.5),
          (5.5,2.5,6.5,1.5),(8.5,3.5,9.5,2.8),(8.5,1.5,9.5,2.2)]
for x1,y1,x2,y2 in arrows:
    ax.annotate('', xy=(x2,y2), xytext=(x1,y1),
                arrowprops=dict(arrowstyle='->', color='#555', lw=1.8))

plt.tight_layout()
plt.savefig('./output/ch6_rag_flow.png', bbox_inches='tight')
plt.show()
复制代码
向量库: 6份画像已索引

=======================================================
检索到3份相关画像:
  [1] 陈某某(群集1, 趋势:轻微波动, 相似度0.674)
  [2] 林某某(群集1, 趋势:轻微波动, 相似度0.618)
  [3] 钱某某(群集0, 趋势:轻微波动, 相似度0.617)

RAG推荐分析: 该高二学生面临"成绩波动大"与"偏科严重"两大核心问题。结合案例特征,成绩极度不稳定通常源于知识体系存在明显漏洞、受试题难度影响大或陷入"高耗时低产出"的学习瓶颈;偏科则表明文理学科发展失衡。此外,高二阶段学业压力增大,成绩起伏易引发心理波动,且需警惕潜在的考勤或心理隐患。因此,干预策略需聚焦于知识漏洞修补、弱势学科强化、学习效率提升及心理状态疏导。
推荐方案:
  → 开展精准学情诊断与知识漏洞修补:利用错题本和知识图谱定位导致成绩波动的核心薄弱点,进行专项突破与限时训练,降低试题难度变化对成绩的影响,提升考试状态的稳定性。
  → 制定弱势学科专项提升与时间管理计划:针对偏科问题,重新规划文理学习时间分配,采用"保优势、补弱势"策略,从弱势学科的基础知识抓起,逐步缩小文理分差,促进学科均衡发展。
  → 优化学习方法与加强心理生活关怀:指导学生改进学习策略,提高课堂与自习效率,打破"高耗时低产出"瓶颈;同时密切关注其心理状态、考勤及生活情况,及时疏导高二学业压力,提供必要的心理支持。

七、推荐效果量化评估

评估方法论

推荐系统的评估不能仅依赖主观感受或个案展示,需要建立系统的量化评估框架。本章从三个维度对推荐策略进行评测:

评估指标

  1. RMSE(均方根误差): 衡量预测分数与实际分数之间的偏差。我们采用留出法:从每个学生的已知成绩中随机抽取2门作为测试集,剩余学科用于训练模型,然后比较模型预测值与测试集真实值的RMSE。RMSE越低,说明预测越准确。

  2. NDCG@K(归一化折损累积增益): 这是信息检索领域的标准指标,衡量推荐列表的排序质量。核心思想是:真正优秀的学科应该排在推荐列表的前面。NDCG值为1表示推荐排序与理想排序完全一致。

  3. Diversity@K(推荐多样性): 计算推荐列表中各学科之间的平均不相似度(1 - 学科间余弦相似度)。多样性高的推荐列表能避免"信息茧房"效应,鼓励学生探索不同学科方向。

对比基线

我们将Item-CF和SVD的推荐结果与一个简单基线进行对比:用全体学生的全局均分作为所有预测值。如果一个复杂方法的RMSE显著低于基线,说明该方法确实捕获了有意义的个性化信息。

7.1 评估指标计算

采用留出法计算RMSE,从每个学生的已知成绩中随机抽取2门作为测试集;同时计算NDCG@3衡量推荐列表的排序质量,以及Diversity@3衡量推荐多样性。

python 复制代码
# ==== 评估指标计算 ====
np.random.seed(2024)
test_pairs = []
for uid in rating_mat.index[:200]:
    known = rating_mat.loc[uid].dropna()
    if len(known) >= 5:
        test_subs = known.sample(min(2, len(known)), random_state=2024).index.tolist()
        for sub in test_subs:
            test_pairs.append((uid, sub, known[sub]))

test_df = pd.DataFrame(test_pairs, columns=['uid','subject','actual'])
print(f'测试集: {len(test_df)}条(用户-学科-实际分数)')

test_df['pred_itemcf'] = test_df.apply(
    lambda r: item_cf_predict(r['uid'], r['subject']), axis=1)
test_df['pred_svd'] = test_df.apply(
    lambda r: R_pred_df.loc[r['uid'], r['subject']] if r['uid'] in R_pred_df.index else np.nan,
    axis=1)
test_df['pred_baseline'] = global_mean

def calc_rmse(series_pred, series_actual):
    m = series_pred.notna()
    return np.sqrt(((series_pred[m] - series_actual[m])**2).mean())

rmse_baseline = calc_rmse(test_df['pred_baseline'], test_df['actual'])
rmse_itemcf = calc_rmse(test_df['pred_itemcf'], test_df['actual'])
rmse_svd = calc_rmse(test_df['pred_svd'], test_df['actual'])

print(f'\n=== RMSE对比 ===')
print(f'  基线(全局均值): {rmse_baseline:.2f}')
print(f'  Item-CF:        {rmse_itemcf:.2f}')
print(f'  SVD矩阵分解:    {rmse_svd:.2f}')

# ---- NDCG@3 ----
def ndcg_at_k(uid, k=3):
    actual = rating_mat.loc[uid].dropna().sort_values(ascending=False)
    if len(actual) < k: return np.nan
    recs = item_cf_recommend(uid, n=k)
    if not recs: return 0
    rec_subs = [r[0] for r in recs]
    dcg = sum([actual.get(sub, 0) / np.log2(i+2) for i, sub in enumerate(rec_subs)])
    ideal = actual.nlargest(k).values
    idcg = sum([ideal[i] / np.log2(i+2) for i in range(min(k, len(ideal)))])
    return dcg / idcg if idcg > 0 else 0

ndcg_scores = []
for uid in rating_mat.index[:100]:
    n = ndcg_at_k(uid)
    if pd.notna(n): ndcg_scores.append(n)
avg_ndcg = np.mean(ndcg_scores) if ndcg_scores else 0
print(f'\n  Item-CF NDCG@3: {avg_ndcg:.3f} (基于{len(ndcg_scores)}名学生)')

# ---- 推荐多样性 ----
def diversity_score(uid, n=3):
    recs = item_cf_recommend(uid, n=n)
    if len(recs) < 2: return np.nan
    rec_subs = [r[0] for r in recs]
    sims = []
    for i in range(len(rec_subs)):
        for j in range(i+1, len(rec_subs)):
            if rec_subs[i] in item_sim.index and rec_subs[j] in item_sim.columns:
                sims.append(item_sim.loc[rec_subs[i], rec_subs[j]])
    return 1 - np.mean(sims) if sims else np.nan

div_scores = []
for uid in rating_mat.index[:100]:
    d = diversity_score(uid)
    if pd.notna(d): div_scores.append(d)
avg_div = np.mean(div_scores) if div_scores else 0
print(f'  Item-CF Diversity@3: {avg_div:.3f}')

# ---- 方法对比总结表 ----
comparison = pd.DataFrame({
    '指标': ['RMSE↓', 'NDCG@3↑', 'Diversity@3↑', '冷启动处理', '语义理解', '可解释性'],
    '基线(均值)': [f'{rmse_baseline:.1f}', '-', '-', '好', '无', '低'],
    'Item-CF': [f'{rmse_itemcf:.1f}', f'{avg_ndcg:.3f}', f'{avg_div:.3f}', '中', '无', '中'],
    'SVD': [f'{rmse_svd:.1f}', '-', '-', '好', '无', '低'],
    'LLM+RAG': ['-', '-', '-', '好', '强', '高']
})
print(f'\n=== 多方法对比 ===')
print(comparison.to_string(index=False))

# ---- 可视化 ----
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

ax = axes[0]
methods = ['基线', 'Item-CF', 'SVD']
rmse_vals = [rmse_baseline, rmse_itemcf, rmse_svd]
colors = ['#bab0ac', '#4e79a7', '#e15759']
bars = ax.bar(methods, rmse_vals, color=colors, edgecolor='white', width=0.5)
for bar, val in zip(bars, rmse_vals):
    ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.5,
            f'{val:.1f}', ha='center', fontproperties=zh_font, fontsize=11)
ax.set_ylabel('RMSE', fontproperties=zh_font)
ax.set_title('推荐精度: RMSE对比', fontproperties=zh_font, fontsize=13)

ax = axes[1]
valid_svd = test_df[test_df['pred_svd'].notna()]
ax.scatter(valid_svd['actual'], valid_svd['pred_svd'], alpha=0.2, s=8, color='#4e79a7')
lims = [min(valid_svd['actual'].min(), valid_svd['pred_svd'].min()),
        max(valid_svd['actual'].max(), valid_svd['pred_svd'].max())]
ax.plot(lims, lims, 'r--', lw=1, label='完美预测线')
ax.set_xlabel('实际分数', fontproperties=zh_font)
ax.set_ylabel('SVD预测分数', fontproperties=zh_font)
ax.set_title('SVD预测值 vs 实际值', fontproperties=zh_font, fontsize=13)
ax.legend(prop=zh_font)

plt.tight_layout()
plt.savefig('./output/ch7_evaluation.png', bbox_inches='tight')
plt.show()
复制代码
测试集: 396条(用户-学科-实际分数)

=== RMSE对比 ===
  基线(全局均值): 26.43
  Item-CF:        18.95
  SVD矩阵分解:    6.97

  Item-CF NDCG@3: 0.000 (基于100名学生)
  Item-CF Diversity@3: 0.051

=== 多方法对比 ===
          指标 基线(均值) Item-CF SVD LLM+RAG
       RMSE↓   26.4    18.9 7.0       -
     NDCG@3↑      -   0.000   -       -
Diversity@3↑      -   0.051   -       -
       冷启动处理      好       中   好       好
        语义理解      无       无   无       强
        可解释性      低       中   低       高

7.2 多方法对比总结

将Item-CF、SVD和全局均值基线的推荐结果进行系统对比,从RMSE、NDCG、多样性、冷启动处理、语义理解和可解释性六个维度评估各方法的优劣。

八、总结与展望

核心发现

本研究聚焦数据驱动的学生画像构建与个性化推荐方法,在算法设计、体系搭建与实证校验三个方向完成了系统性研究,得到以下关键结论:

研究维度 方法 核心结论
群体划分 PCA降维结合K-Means聚类 在经PCA降维映射的特征空间内,无监督聚类算法可自动划分出多组特征分野明确的学生群体。与传统Z-Score规则分箱方案相比,聚类方法能够识别出"总分优异但成绩稳定性不足""各科发展均衡但消费行为偏离常模"等规则逻辑无法捕获的交叉型特征范式。
学业时序研判 Mann-Kendall趋势检验法 样本中约15%的学生学业走势呈现统计学意义上的显著升降趋势。这一结果说明,仅基于静态平均成绩构建的画像会丢失关键的成长动态信息,时序分析能够为画像体系补充时间维度的演化特征。
预测性能 SVD矩阵分解 仅需设置5维潜在因子,SVD矩阵分解模型的预测RMSE即可优于Item-CF与全局均值两种基线方法。该模型能够挖掘学科之间的隐性关联结构,例如数理逻辑维度的潜在因子会同时作用于数学、物理及技术类学科的成绩表现。
语义表征能力 大语言模型结合TF-IDF量化 大语言模型输出的画像文本,在语义信息密度上远高于传统规则标签的简单组合。TF-IDF量化结果验证了画像关键词与教育领域的高度契合度,t-SNE可视化结果也显示不同聚类群体的画像在语义向量空间中存在清晰的边界。
推荐系统架构 检索增强生成(RAG)框架 基于检索增强生成的推荐框架,成功实现了画像语义信息与推荐推理过程的深度结合,能够输出附带实证依据与成因解读的可解释推荐文本,适配教师教学决策与家校沟通等真实应用场景。

技术路线图

复制代码
数据工程 → PCA降维 → K-Means聚类 → 群体画像
    ↓                                    ↓
时序分析 → Mann-Kendall → 趋势标签 ──→ LLM画像生成
    ↓                                    ↓
Item-CF + SVD → 数值推荐 ──────→ RAG融合推荐

本研究的技术路径遵循"从宏观群体到微观个体、从静态快照到动态演化、从数值特征到自然语言"的逐层深化逻辑:先通过聚类算法完成学生群体的宏观划分,再通过时序分析刻画个体的动态成长轨迹,随后通过大语言模型完成数值特征到自然语言画像的转译,最终依托RAG框架将语义信息注入推荐生成的全过程。

方法论反思

  1. 聚类与规则的互补价值:无监督聚类能够基于数据本身的分布挖掘自然群体结构,但其输出结果的业务可解释性弱于人工定义的规则体系。在实际应用中,可采用"聚类先行发现结构、规则后续补充语义"的组合策略,为每个聚类簇匹配具备业务含义的标签。
  2. 大语言模型的生成偏差风险:大模型生成的画像虽然语义表达更饱满,但存在对少量样本数据过度引申的问题,例如仅凭单次考试异常就推断学生存在心理层面的问题。在工程落地中必须引入事实校验模块,确保画像描述均有对应的数据支撑,避免无依据的泛化推断。
  3. 推荐精度与可解释性的平衡取舍:SVD矩阵分解在预测精度上表现最优,但潜在因子缺乏直观的教育含义,可解释性最弱;RAG框架生成的推荐可解释性最强,但效果高度依赖画像的构建质量。将两类方法进行融合,是兼顾预测性能与可解释性的最优落地路径。

未来方向

  1. 深度时序模型升级:引入Transformer、LSTM等深度序列学习模型对学生成绩时序数据进行端到端建模,突破Mann-Kendall检验的线性趋势假设限制,更精准地捕捉学生学业发展的非线性演化规律。
  2. 因果性分析拓展:引入DoWhy等因果推断工具包,将研究从相关性分析层面推进至因果性分析层面,精准定位能够切实影响学业表现的可干预变量,而非仅停留在统计关联的结论上。
  3. 多模态数据整合:进一步纳入课堂行为视频、作业文本内容、校园社交关系等多类型非结构化数据,打造维度更全面、表征更立体的学生画像体系。
  4. 流式增量更新机制:构建可在线迭代的画像更新体系,当新的行为数据接入时,自动更新聚类标签与推荐模型参数,形成"画像生成---推荐输出---反馈迭代"的自优化闭环。
  5. 算法公平性校验:建立全流程的公平性审计机制,系统评估推荐结果在不同性别、班级、族群等群体间的分布差异,防范推荐算法放大教育资源分配不公的潜在风险。

参考文献

  1. MacQueen J. Some methods for classification and analysis of multivariate observations. Berkeley Symposium on Mathematical Statistics and Probability, 1967.
  2. Koren Y., Bell R., Volinsky C. Matrix Factorization Techniques for Recommender Systems. Computer, 42(8), 2009.
  3. Mann H.B. Nonparametric tests against trend. Annals of Mathematical Statistics, 16(1): 106-116, 1945.
  4. Lewis P. et al. Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks. NeurIPS, 2020.
  5. Touvron H. et al. LLaMA: Open and Efficient Foundation Language Models. arXiv, 2023.
  6. Sarwar B. et al. Item-based Collaborative Filtering Recommendation Algorithms. WWW, 2001.

感谢阅读!