Python 学习行为分析:学生作答数据的特征工程与模型训练
2026/7/23 11:51:19 网站建设 项目流程

Python 学习行为分析:学生作答数据的特征工程与模型训练

一、平台存了 500 万条做题记录,却不知道学生为什么会放弃

一个在线教育平台每天产生几十万条学生作答记录——什么时候开始做题、答了哪道题、花了多少秒、是对是错、错了之后有没有看解析。这些数据孤零零地躺在 MySQL 里,没人知道怎么用。产品经理问:"为什么这个学生连续 7 天登录,第 8 天突然不来了?"数据分析师给不出答案——因为答案不在某一条记录里,而在"一系列行为模式"中。

学习行为分析的挑战是:原始数据(答题日志)和业务洞察(流失原因、薄弱点)之间有巨大的语义鸿沟。这个鸿沟需要用特征工程来桥接——将原始的"时间-事件序列"转化为模型可理解的特征向量。

二、学习行为特征工程的完整 Pipeline

从原始日志到模型特征,需要经历三个层次的抽象:

每提升一个抽象层,特征的预测能力增强,但可解释性下降。对于学习行为分析,三层特征全保留,让模型自己学权重——事实证明,低层统计特征和高层时序特征对预测同等重要。

三、Python 实现:学习行为特征提取

import pandas as pd import numpy as np from typing import Dict, List, Optional, Tuple from dataclasses import dataclass from sklearn.preprocessing import StandardScaler from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import train_test_split from sklearn.metrics import classification_report import warnings warnings.filterwarnings('ignore') @dataclass class AnswerLog: """单条作答记录""" student_id: str question_id: str knowledge_id: str # 考察的知识点 is_correct: bool time_spent: int # 作答耗时(秒) timestamp: pd.Timestamp viewed_solution: bool # 是否查看解析 skipped: bool # 是否跳过 class BehaviorFeatureExtractor: """学习行为特征提取器""" def __init__(self, session_gap_minutes: int = 30): self.session_gap = pd.Timedelta(minutes=session_gap_minutes) self.scaler = StandardScaler() def _sessionize(self, logs: pd.DataFrame) -> pd.DataFrame: """阶段1:会话切分(>30分钟无操作视为新会话)""" logs = logs.sort_values(['student_id', 'timestamp']) logs['prev_timestamp'] = logs.groupby( 'student_id' )['timestamp'].shift(1) logs['gap'] = logs['timestamp'] - logs['prev_timestamp'] # 新会话标记 logs['is_new_session'] = ( logs['gap'] > self.session_gap ) | logs['prev_timestamp'].isna() logs['session_id'] = logs.groupby( 'student_id' )['is_new_session'].cumsum() return logs def extract_basic_features( self, logs: pd.DataFrame ) -> pd.DataFrame: """阶段2:基础统计特征""" features = logs.groupby('student_id').agg( total_questions=('question_id', 'count'), correct_rate=('is_correct', 'mean'), avg_time_spent=('time_spent', 'mean'), std_time_spent=('time_spent', 'std'), max_time_spent=('time_spent', 'max'), view_solution_rate=('viewed_solution', 'mean'), skip_rate=('skipped', 'mean'), total_sessions=('session_id', 'nunique'), unique_knowledge=('knowledge_id', 'nunique'), ).reset_index() # 填空缺失值 features['std_time_spent'] = features[ 'std_time_spent' ].fillna(0) return features def extract_temporal_features( self, logs: pd.DataFrame, window_days: List[int] = [1, 3, 7, 14] ) -> pd.DataFrame: """阶段3:时序特征 —— 滚动窗口统计""" logs = logs.copy() logs['date'] = logs['timestamp'].dt.date now = logs['timestamp'].max() all_features = [] for student_id, group in logs.groupby('student_id'): row = {'student_id': student_id} for window in window_days: cutoff = now - pd.Timedelta(days=window) recent = group[group['timestamp'] >= cutoff] row[f'questions_{window}d'] = len(recent) row[f'correct_rate_{window}d'] = ( recent['is_correct'].mean() if len(recent) > 0 else 0 ) row[f'avg_time_{window}d'] = ( recent['time_spent'].mean() if len(recent) > 0 else 0 ) row[f'sessions_{window}d'] = ( recent['session_id'].nunique() if len(recent) > 0 else 0 ) # 趋势特征:最近3天 vs 前3-7天 recent_3d = group[ group['timestamp'] >= now - pd.Timedelta(days=3) ] older_3d = group[ (group['timestamp'] >= now - pd.Timedelta(days=7)) & (group['timestamp'] < now - pd.Timedelta(days=3)) ] if len(recent_3d) > 0 and len(older_3d) > 0: row['correct_rate_trend'] = ( recent_3d['is_correct'].mean() - older_3d['is_correct'].mean() ) row['time_trend'] = ( recent_3d['time_spent'].mean() - older_3d['time_spent'].mean() ) else: row['correct_rate_trend'] = 0 row['time_trend'] = 0 # 连续错误/正确模式 last_10 = group.tail(10)['is_correct'].values row['consecutive_errors'] = self._count_consecutive( last_10, False ) row['consecutive_corrects'] = self._count_consecutive( last_10, True ) all_features.append(row) return pd.DataFrame(all_features) @staticmethod def _count_consecutive(series: np.ndarray, target: bool) -> int: """计算最近连续的 target 数量""" count = 0 for val in reversed(series): if val == target: count += 1 else: break return count def prepare_training_data( self, logs: pd.DataFrame, labels: pd.DataFrame ) -> Tuple[np.ndarray, np.ndarray, List[str]]: """准备训练数据""" logs = self._sessionize(logs) basic_feats = self.extract_basic_features(logs) temporal_feats = self.extract_temporal_features(logs) # 特征融合 merged = basic_feats.merge(temporal_feats, on='student_id') merged = merged.merge(labels, on='student_id') feature_cols = [c for c in merged.columns if c not in ['student_id', 'label']] X = merged[feature_cols].fillna(0).values X = self.scaler.fit_transform(X) y = merged['label'].values return X, y, feature_cols def train_risk_model( self, X: np.ndarray, y: np.ndarray, feature_names: List[str] ) -> Tuple[RandomForestClassifier, Dict]: """训练流失风险预测模型""" X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 ) model = RandomForestClassifier( n_estimators=100, max_depth=8, min_samples_leaf=10, random_state=42, n_jobs=-1, ) model.fit(X_train, y_train) y_pred = model.predict(X_test) report = classification_report( y_test, y_pred, output_dict=True ) # 输出 Top-5 重要特征 importances = sorted( zip(feature_names, model.feature_importances_), key=lambda x: x[1], reverse=True ) print("Top-5 重要特征:") for name, imp in importances[:5]: print(f" {name}: {imp:.4f}") return model, report # 示例使用 def example_usage(): """演示完整的特征工程 + 模型训练流程""" # 模拟数据 np.random.seed(42) n_students = 1000 n_logs = 50000 students = [f'S{i:04d}' for i in range(n_students)] logs_data = [] for _ in range(n_logs): sid = np.random.choice(students) logs_data.append({ 'student_id': sid, 'question_id': f'Q{np.random.randint(1, 5001)}', 'knowledge_id': f'K{np.random.randint(1, 101)}', 'is_correct': np.random.random() > 0.35, 'time_spent': int(np.random.exponential(60)), 'timestamp': pd.Timestamp('2026-06-01') + pd.Timedelta( days=np.random.randint(0, 30) ), 'viewed_solution': np.random.random() > 0.6, 'skipped': np.random.random() > 0.85, }) logs_df = pd.DataFrame(logs_data) labels_df = pd.DataFrame({ 'student_id': students, 'label': np.random.choice([0, 1], n_students, p=[0.8, 0.2]), }) extractor = BehaviorFeatureExtractor() X, y, feature_names = extractor.prepare_training_data( logs_df, labels_df ) model, report = extractor.train_risk_model(X, y, feature_names) print(f"\n模型准确率: {report['accuracy']:.3f}") return model, extractor

四、边界分析与 Trade-offs

特征爆炸问题:滑动窗口 × 统计指标 = 特征数爆炸(7 个窗口 × 8 个指标 = 56 个特征)。但实际上大部分特征之间存在高相关性(如questions_1dquestions_3d),需要通过特征选择(卡方检验或基于树的特征重要性)降维。经验是保留 Top-15 个特征,模型效果与全特征相当。

冷启动学生的处理:新学生没有时序特征(所有滚动窗口都是 0),模型会把他们统一预测为"低风险"——因为缺少活动信号。应该在输出预测时标注"数据不足,置信度低",同时基于静态人口学特征(年级、注册渠道)做辅助预测。

样本不均衡的典型问题:流失学生通常只占 5%-15%,模型会倾向于预测"不流失"。用 SMOTE 过采样或调整类别权重(class_weight='balanced')可以有效缓解。但要注意:SMOTE 生成的人工样本会降低模型在真实数据上的校准度。

特征因果 vs 相关:模型可能发现"跳过题目多 = 流失风险高",但这是相关性而不是因果——学生在决定放弃之前,行为已经发生变化。真正的 actionable insight 是:当检测到"连续跳过 3 题"信号时,推送干预(如降低难度或推荐视频),而不是等到学生已经流失才触发挽留。

五、总结

学习行为分析的核心是"从事件流到特征向量"的转化过程。分三个阶段提取——基础统计(快照)、时序窗口(趋势)、序列模式(状态变化)——是实践证明有效的特征工程范式。代码上要注意三点:会话切分的阈值调优(30 分钟是通用经验,但不同学段可能需要调整)、特征空值处理(新学生缺少的历史特征用 0 填充,但要标记 low_confidence)、以及模型输出的校准(不能只给概率,要附带置信度和关键特征贡献)。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询