ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

成绩与录取率预测:多源数据宽表构建、特征设计与时间序列建模

成绩与录取率预测:多源数据宽表构建、特征设计与时间序列建模 简介这是一份面向中高级Python学习者的数据分析实战资源聚焦成绩、录取率与经济社会指标的统计与预测。资源为单个docx文档压缩包约17KB内容浓缩但覆盖完整。文档按四个作业模块展开成绩数据分析涉及数据导入、基本统计描述、缺失值均值插补、StudyHours与Grade的相关系数计算以及一元线性回归和及格标记生成大学录取数据分析包含字段重命名、按学校等级与GRE均值的聚合比较、录取几率多元回归及显著因素解读社会经济数据处理关注收入缺失值转换、文化程度收入差异检验、年龄字段衍生与虚拟变量构建并建立收入回归模型儿童与村庄调查数据则涉及负值缺失定义、分级统计、多文件合并及字段前缀处理。这些任务都围绕真实业务场景设计强调用Pandas与NumPy完成数据清洗、预处理、建模和可视化有助于读者掌握从数据准备到模型解释的完整分析链条。目前已有85人学习下载适合需要独立完成课程练习或项目作业的数据分析师也可作为系统性的操作参考。1. 为什么成绩、录取率和经济指标要放进同一个分析管道做教育或招生数据分析的人手里通常握着三张表学生成绩表、各地区录取率表、地区经济指标表。单独看每一张都能做点描述统计但一旦你想回答“明年的录取率会升还是会降”“哪个地区的录取率受经济影响最大”就必须把这三类数据放进同一个分析管道。很多人直接读表合并然后跑一个线性回归发现R²好看到不行第二年一上线预测就崩。原因不是模型不够强而是成绩、录取率、经济指标在时间和地区两个维度上存在自相关、伪相关和比率边界问题。这套分析思路会沿着“统计→预测”的顺序铺开从宽表构建、分组透视到特征设计与避坑验证每一步都给出能直接跑的Python代码和参数说明适合大部分从业者和准备做数据分析实践的学习者。2. 三源宽表构建成绩表、录取率表和经济指标表的字段对齐与清洗2.1 三源数据结构和字段预检常见的成绩表是一条记录对应一名学生或一次考试字段一般是student_id, exam_year, subject, score。录取率表是一行对应一个地区在某年的招生结果字段是region, year, enrollment_rate, applicants, admitted。经济指标表则是region, year, gdp, per_capita_income, edu_expenditure这类年鉴口径。问题在于成绩表里通常没有地区字段只有学校代码或考区代码你得先准备一张学校到地区的映射表否则后面没法把成绩聚合到“地区×年份”这个粒度上。所以我不建议一上来就pd.merge而是先做字段预检。用下面这段代码快速确认三个源表的记录数、列类型和缺失情况import pandas as pd score_df pd.read_csv(scores.csv) rate_df pd.read_csv(admission_rate.csv) econ_df pd.read_csv(economy.csv) school_region pd.read_csv(school_region.csv) # 学校ID - 地区名 for name, df in [(score, score_df), (rate, rate_df), (econ, econ_df)]: print(name, shape:, df.shape) print(df.dtypes) print(null counts:\n, df.isna().sum())这段代码做的事情很基础但救命把三个源的形状、字段类型、缺失值一次性打出来。很多人跳过这一步直接合并结果合并后行数暴涨或骤减排查半天才发现是年份字段一个存的是int一个存的是object或者地区名一个叫“北京市”另一个叫“北京”。read_csv里可以加dtype{year: int32, region: str}来固化类型但是先跑一遍预检再决定怎么转更稳。这里有个隐蔽的坑成绩表里的年份可能是“入学年份”而录取率表里的年份是“录取年份”两者天然错位一年。如果不去查清楚后面 merge 出来的每一行都是在拿不对齐的时间点做关联。我一般会在预检阶段额外打印两列的不同年份值比如score_df[year].unique()和rate_df[year].unique()只要发现年份集合不完全一致就先用入学年份减去修业年限去对齐。2.2 用 pandas 完成标准格式化和横向合并字段预检做完后统一格式并合并。这里我强调一个顺序先排序再做合并。很多分析任务都跟“排序统计”有关——比如统计某个地区录取率的历史排名变化或者计算相邻两年的差值这些都需要先按地区、年份排好序否则滚动窗口和滞后特征全是乱的。来看实际操作代码# 统一字段类型和名称 for df in [rate_df, econ_df]: df.columns df.columns.str.strip().str.lower() df[year] df[year].astype(int) score_df[year] score_df[year].astype(int) # 学校映射到地区缺失地区标记为 unknown if region not in score_df.columns: score_df score_df.merge(school_region, onschool_id, howleft) # 对“地区-年份”去重保留最后一条 rate_df rate_df.drop_duplicates(subset[region, year], keeplast) econ_df econ_df.drop_duplicates(subset[region, year], keeplast) # 先以录取率为核心左连接经济指标 merged rate_df.merge(econ_df, on[region, year], howleft) # 再把成绩表聚合成地区-年份的平均分、标准差和考生数 year_score score_df.groupby([region, year], as_indexFalse)[score].agg( avg_scoremean, std_scorestd, stu_countcount ) merged merged.merge(year_score, on[region, year], howleft) merged.head()逻辑说明这里先把两张维度表去重是因为年鉴数据在多次导出时经常出现同一地区同一年的重复行。不去重直接 merge 会变成一对多行数虚高后面统计全部失真。成绩表的聚合用了groupby().agg()把成绩从学生级压缩到地区级这样才能和录取率、经济指标保持在同一个分析粒度上。参数说明as_indexFalse让分组列保留成普通列而不是索引agg()里用“新列名聚合函数”的写法是 pandas 0.25 之后的 named aggregation 语法比旧式的rename干净很多。还有一个常见做法如果成绩表规模很大比如几十万行我会先做一次groupby聚合再 merge而不是直接把所有学生记录 left join 到录取率表上。否则你得到的长表里每个地区出现几百行学生记录建模时地区维度的特征被稀释模型根本学不进去。2.3 处理缺失值并构造“可训练宽表”的边界条件合并后最常见的状况是某些年份某地区的经济指标缺失或者某地区在成绩表里没有任何考生记录。直接dropna()想都不想就删会丢掉大量本身就是有效样本的历史录取率记录。我一般按字段类型分别处理。# 按地区分组对经济指标做线性插值 merged merged.sort_values([region, year]).reset_index(dropTrue) econ_cols [gdp, per_capita_income, edu_expenditure] for col in econ_cols: merged[col] merged.groupby(region)[col].transform( lambda s: s.interpolate(methodlinear, limit_directionboth) ) # 成绩特征缺失时用全局中位数填充 score_cols [avg_score, std_score, stu_count] for col in score_cols: merged[col] merged[col].fillna(merged[col].median()) # 录取率缺失的行不能参与训练删除 merged merged.dropna(subset[enrollment_rate, region, year]) merged.to_csv(merged_wide.csv, indexFalse)逻辑说明经济指标通常随年份平滑变化用组内线性插值比中位数填充更合理limit_directionboth表示首尾年份缺失也允许向前或向后推这样前几年的经济指标也能补上。成绩特征用中位数填充是为了避免把缺失变成“0分”——如果填 0后面的滞后特征会学出一个假信号某些地区突然出现成绩为 0 的年份。dropna只删除录取率本身缺失的行因为录取率是预测目标缺失了就没法做监督学习。参数说明interpolate还可以换methodquadratic但我实际用下来地区样本量小于 10 时二次插值容易过冲线性最稳。到这里“地区×年份”的宽表就建好了。每一行代表一个地区在某一年的一份完整记录包含录取率、经济指标、成绩均值等。这块宽表是后面所有统计和预测的底座值得单独存一个版本文件。我习惯把清洗代码和存文件这一步做成脚本下次数据更新直接重跑。3. 分组统计与相关性透视先回答录取率“和什么有关”3.1 按地区/年份分组的下钻统计在建模之前先做分组统计能让你知道哪些地区“可预测”、哪些地区天生难搞。这里就要用到排序统计的思路先按地区分组再对年份排序然后统计每组的均值和波动。直接看代码region_stats ( merged.groupby(region) .agg( years(year, count), avg_rate(enrollment_rate, mean), std_rate(enrollment_rate, std), avg_score(avg_score, mean), avg_gdp(gdp, mean), ) .reset_index() ) # 变异系数衡量录取率波动波动大说明预测难度高 region_stats[cv_rate] region_stats[std_rate] / region_stats[avg_rate] region_stats.sort_values(cv_rate, ascendingFalse).head(10)逻辑说明std_rate是绝对波动但录取率基数不同的地区无法直接比较。A 地区录取率常年 80%标准差 0.05B 地区录取率常年 20%标准差 0.04。只看标准差A 地区波动更大实际上 B 地区的相对波动反而是 20%更难预测。变异系数std/mean消除基数影响后才能公平地给预测难度排序。参数说明agg里years用的是count如果某地区只有 3 年数据它根本不应该参与全国混合模型训练或者至少要降权否则它会变成噪声样本。如果成绩表带学科或班级字段我建议在这个步骤之后追加一个“学科×地区”的交叉分组透视看不同学科的平均分变化是否和录取率趋势一致。比如“物理类平均分下降、历史类上升”这样的信号对预测某些省份的录取批次很有用。做法很简单把score_df按region, year, subject做一次聚合再回到宽表按地区 join 回来。3.2 录取率与经济指标、成绩的相关性矩阵相关性分析是统计环节里决策价值最高的一步。但直接对原始 GDP 做 Pearson 相关会吃亏因为 GDP 量级和偏度太大。我习惯先取对数再做相关矩阵import numpy as np corr_input merged[ [enrollment_rate, avg_score, gdp, per_capita_income, edu_expenditure, year] ].copy() corr_input[log_gdp] np.log1p(corr_input[gdp]) corr_matrix corr_input.corr() rate_corr corr_matrix[enrollment_rate].sort_values(ascendingFalse) print(rate_corr)参数说明np.log1p是log(x1)避免 GDP 为 0 时出现无穷值corr()默认用 Pearson 相关系数它只衡量线性关系。如果你发现某两个变量的散点图是明显倒 U 形或者指数形Pearson 会严重低估相关性这时改用corr(methodspearman)看秩相关更稳。经验上经济指标和录取率之间的相关系数通常会比较高但你得警惕这是不是共同时间趋势带来的伪相关——这个坑在第 5 章会专门讲。相关矩阵的作用不是告诉你因果关系而是帮你筛选候选特征。比如edu_expenditure和avg_score可能高度相关如果两个都进回归模型会引入多重共线性观察相关矩阵后线性模型里你只保留其中一个就够了。3.3 用透视表观察跨地区的年份效应除了特征相关性还有一个容易被忽略的维度年份。某些年份因为招生计划调整或批次合并所有地区的录取率会同时跳变。这种“年份效应”如果不识别模型会把系统性的年份冲击当成各地区自有的波动导致预测失真。用透视表可以把年份效应暴露出来pivot pd.pivot_table( merged, indexregion, columnsyear, valuesenrollment_rate, aggfuncmean ) # 看所有地区均值的相邻年份变化 year_mean pivot.mean(axis0) print(逐年整体均值:\n, year_mean) print(相邻年份 diff:\n, year_mean.diff())逻辑说明pivot.mean(axis0)算出每一年的全国平均录取率diff()表示相邻年份的整体变化量。如果某年的 diff 绝对值超过 0.05说明存在明显的年份级冲击。比如 2020 年很多地区录取率同时升高那一年就应该把year_cat作为类别特征放进模型或者单独加一个“是否 2020 年”的哑变量。参数说明pivot_table中aggfuncmean是因为同一地区同年可能残留重复行用均值折叠比直接透视安全如果前面清洗时已经去重这里换成pd.pivot也行但保留aggfunc没有坏处。到这一步你已经能回答“现状是什么”哪些地区录取率波动大、哪些经济指标与录取率线性相关、哪些年份存在系统性突变。这些结论直接决定下一步特征怎么设计、要不要按年份拆分建模。4. 预测建模从滞后特征到随机森林回归的时间线方案4.1 特征工程为什么不能直接用当年的经济指标预测当年录取率建模的第一原则是预测时只有历史数据可用。站在 2024 年底预测 2025 年录取率2025 年的 GDP 和经济指标还没公布所以模型输入只能用 2024 年及之前的值。很多人图省事把当年的 GDP 和当年的录取率一起放进训练集模型训练时看起来准确率很高一上线做真实预测就抓瞎因为拿不到“当年的特征”。解决办法是做滞后特征用 t-1 年和 t-2 年的经济指标、成绩统计、录取率来预测 t 年的录取率。代码实现如下# 先按地区-年份排序保证 shift 的方向正确 merged merged.sort_values([region, year]).reset_index(dropTrue) lag_cols [gdp, per_capita_income, edu_expenditure, avg_score, enrollment_rate] for col in lag_cols: for lag in [1, 2]: merged[f{col}_lag{lag}] merged.groupby(region)[col].shift(lag) # 近3年录取率的滚动均值作为“趋势势头”特征 merged[rate_roll3] ( merged.groupby(region)[enrollment_rate] .transform(lambda s: s.rolling(3, min_periods1).mean()) ) # 年份数值化和类别化 merged[year_num] merged[year] - merged[year].min() merged[year_cat] merged[year].astype(category).cat.codes # 去掉滞后特征产生的 NaN每个地区最早两年没有lag值 model_data merged.dropna(subset[enrollment_rate_lag1, gdp_lag1]).copy()逻辑说明groupby(region).shift(lag)确保滞后只发生在同一地区内部而不是把 B 地区去年的数据错位到 A 地区。这个错位是新手最容易踩的坑——直接在整列上shift()会得到跨地区的“假滞后”模型还能学到离谱规律。滚动均值rolling(3, min_periods1)给模型一个“最近几年录取率是什么水平”的平滑特征它能刻画地区录取率的惯性。参数说明滞后阶数选 1 和 2 是经验值如果你的数据覆盖 10 年以上可以考虑 3 阶但滞后太多会让早期样本几乎全被 NaN 吃掉样本量急剧缩水。year_num是数值化的年份偏移线性模型能捕捉全局线性趋势year_cat是类别编码适合树模型去学年份的跳变效应。注意不要把类别编码喂给线性回归因为2024和2025这两个类别编码相差 1线性模型会误以为两个年份相邻的影响差异是均匀的——尤其在 2020 年这种突变年份这种假设会失败。4.2 时间顺序切分训练集和测试集必须按时间划分这一点再怎么强调都不为过。用随机切分的train_test_split来做面板数据预测就是自欺欺人。正确的做法是以某一年为切分线切分点之前做训练之后做测试模拟“使用历史预测未来”的真实场景。from sklearn.ensemble import RandomForestRegressor from sklearn.linear_model import LinearRegression from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score features [ gdp_lag1, per_capita_income_lag1, edu_expenditure_lag1, avg_score_lag1, enrollment_rate_lag1, enrollment_rate_lag2, rate_roll3, year_num, year_cat ] X model_data[features] y model_data[enrollment_rate] cut_year 2021 X_train X[model_data[year] cut_year] y_train y[model_data[year] cut_year] X_test X[model_data[year] cut_year] y_test y[model_data[year] cut_year] models { linear: LinearRegression(), random_forest: RandomForestRegressor( n_estimators300, min_samples_leaf5, random_state42 ), } for name, model in models.items(): model.fit(X_train, y_train) pred model.predict(X_test) print(f{name} MAE: {mean_absolute_error(y_test, pred):.4f}, fRMSE: {mean_squared_error(y_test, pred, squaredFalse):.4f}, fR2: {r2_score(y_test, pred):.4f})逻辑说明训练集里每一条样本的第 t 行使用的是 t-1 和 t-2 年的特征目标却还是第 t 年的录取率测试集同理。切分线选 2021 年意味着 2021 年及以后的所有地区样本都不会参与拟合这样测出来的误差才是真实的向前预测误差。参数说明随机森林里我固定n_estimators300防止样本太小导致过拟合min_samples_leaf5限制叶子最少样本数这里的效果是防止模型记住某几个地区的特殊波动。线性回归没做标准化和共线性处理只是当一个数值基线如果你的学习目标不是调参而是确认流程这个对比已经足够。这里说一下year_cat对线性模型不适用的问题。如果你硬要用线性回归最好把year_cat替换成pd.get_dummies(model_data, columns[year])生成的 one-hot 列或者干脆只用year_num。实际项目里我会为线性回归和树模型准备两套特征矩阵避免迁就某一类模型。4.3 预测目标变体除了录取率还要预测成绩分位点有些业务场景预测的是平均分或录取分数线而不是录取率。成绩预测相对简单——它是连续实数近似正态分布。但录取率预测有个天然难题它的值域在 [0,1] 之间普通线性回归会预测出负数或大于 1 的值业务方看到 1.3 的录取率直接会觉得你疯了。解决方案是对录取率先做 logit 变换再回归最后变换回来from scipy.special import logit, expit eps 1e-3 model_data[rate_logit] logit( model_data[enrollment_rate].clip(eps, 1 - eps) ) y_logit model_data.loc[X.index, rate_logit] # 线性回归拟合 logit 值 lr_logit LinearRegression() lr_logit.fit(X_train, y_logit[X_train.index]) pred_logit lr_logit.predict(X_test) # 把预测的 logit 换回概率值 pred_rate expit(pred_logit) print(Logit 变换后 MAE:, mean_absolute_error(y_test, pred_rate))逻辑说明clip(eps, 1-eps)把 0 和 1 这两个极端值压缩到接近但没有到边界的范围否则logit会在 0 和 1 处产生正负无穷模型直接崩掉。变换后的目标落在整个实数轴上线性回归可以正常拟合预测结果再通过expit映射回 [0,1]自然保住了业务可解释性。参数说明eps取1e-3时原始值 0 会被压成 0.001logit 约为 -6.9如果样本里有大量真实 0 或 1建议放宽为1e-2以避免极端 logit 值主导损失函数。最后补充一句如果你的样本量足够大比如超过 2000 条地区年份样本并且预测的是成绩这种强序列特征后续可以尝试 LightGBM 或 LSTM。但我不建议一上来就上复杂模型——先把这个基于滞后特征和随机森林的基线跑通把数据清洗和验证规范固定下来再谈模型升级。这是最稳的落地顺序。5. 避坑清单成绩与录取率预测中最常见的五个翻车点5.1 录取率是 0-1 区间目标却硬用普通线性回归拟合现象模型预测出某个地区的录取率是 1.3 或者 -0.2业务方看到输出第一反应是质疑你的数据质量后面再解释都费劲。原因最小二乘回归假设目标变量值域是(-∞, ∞)但录取率被业务定义死死锁在 [0,1] 内。当训练集中有大量地区录取率接近 0.9 甚至 0.95 时线性模型为了去拟合这些样本很容易把回归直线延伸到区间外。解决用第 4.3 节提到的 logit 变换把目标映射到实数域建模最后再expit变换回来。如果只是临时展示可以对预测结果做clip但这不是统计上能自圆其说的做法只适合快速看效果。5.2 经济指标和录取率出现“伪相关”双双随时间上涨现象相关矩阵显示 GDP 与录取率的 Pearson 系数高达 0.85你把它当成最重要的特征结果模型测试集误差比只用滞后录取率还要大。原因GDP 逐年增长录取率在很多地区也呈现缓慢上升趋势两个序列共享了“时间趋势”这个隐藏变量所以相关系数虚高。解决在算相关之前先对每个序列做一阶差分比如merged[gdp_growth] merged.groupby(region)[gdp].pct_change()然后用差分序列重新计算相关矩阵。实际建模时我会同时保留year_num作为全局趋势特征把“共同的年份趋势”和“地区独立差异”分开让模型学。5.3 用随机划分代替时间切分造成未来数据泄漏现象训练集上 R² 高达 0.93切到测试集后 R² 变成负数无论怎么调参都没有改善。原因教材里的train_test_split默认随机划分但面板数据中第 t 年的录取率和 t-1 年高度相关随机划分会把“未来年份”混进训练集模型提前看到了答案。解决严格用年份阈值切分或使用TimeSeriesSplit做交叉验证。下面这个代码片段可以用来检查你在多个时间窗上的稳定性from sklearn.model_selection import TimeSeriesSplit tscv TimeSeriesSplit(n_splits3) # 每次训练集都是过去测试集都是未来 for fold, (train_idx, test_idx) in enumerate(tscv.split(X)): train_year_max model_data[year].iloc[train_idx].max() test_year_min model_data[year].iloc[test_idx].min() print(ffold {fold}: 训练截止 {train_year_max}测试从 {test_year_min} 开始)注意TimeSeriesSplit不会打乱顺序而且要求数据已经按时间排好。如果每个 fold 的误差波动很大说明某些年份存在结构突变模型缺乏跨时间泛化能力这时不要急着调参先去检查那些年份有没有政策冲击。5.4 成绩预测出现负分因为训练集有极端低分且特征不足现象预测某地区下一年的平均成绩输出 -52 分明显荒谬。原因某些地区考生数只有 2 个人平均分被一个极端低分拉到极低而模型没见过这几个特征的组合外推时就超出了合理范围。解决有两个实用手段一是过滤样本只保留stu_count 20的地区年份记录参与建模二是对平均分做 5% 和 95% 分位的 Winsorize 截尾。代码如下low, high merged[avg_score].quantile([0.05, 0.95]) merged[avg_score_clip] merged[avg_score].clip(low, high)clip比直接删除极端值好因为被截尾的样本还保留在训练集里只是把极端值压到了边界模型不会因为那个 -52 分学到荒谬的直线斜率。注意这种做法只能用于建模特征不能用于最终报告的历史成绩呈现。5.5 RMSE 被极少数高录取率地区带偏模型只优化了“热门地区”现象整体 RMSE 看起来很漂亮但分地区看误差录取率高的几个地区误差大得惊人其他地区误差都很小。原因RMSE 对误差做平方惩罚高录取率地区的预测值和真实值差值大平方后贡献了绝大部分误差模型为了压低这个平方和会倾斜去拟合这些地区牺牲大部分低录取率地区。解决改用 MAE 作为主要评估指标或者按地区样本量加权计算分组 MAE。实操时我会分别统计“录取率低于 0.3”和“高于 0.7”两组样本的 MAE如果高组误差明显偏大就说明模型被高录取率地区主导了。这时回到目标变换对录取率做 logit 处理后再预测往往能缓解。这五个坑没有一个是模型参数学出来的全部是数据定义和数据切分层面的问题。所以我的经验是建模前先把“目标变换、时间切分、评估指标”这三个决策固化下来再开始调参。6. 验证技巧用残差自相关和滚动回溯判断预测靠不靠谱模型训练完评估指标也打了分我会额外做两个很少人做但很管用的验证防止模型在“假预测”。第一个是残差自相关检验。如果模型已经捕捉了时间模式那测试集残差真实值减预测值不应该存在明显的自相关如果残差在时间顺序上还有规律说明模型漏掉了重要信息。代码很简单import pandas as pd # 假设 pred 是对应测试集的预测值 resid pd.Series(y_test.to_numpy() - pred).sort_index() lag1_ac resid.autocorr(lag1) print(残差 lag1 自相关:, lag1_ac) # 经验阈值绝对值超过 0.3 就说明残差里有时间模式如果lag1_ac超过 0.3我的常见做法是回去加一个“上一年残差”特征或者检查是否漏掉了年份哑变量。不做这一步你很可能把有偏的模型当成好模型发布。第二个是滚动起点回溯。固定的单一切分只能告诉你“在某个历史时点之后的表现”但不能证明模型现在依然可用。我一般会连续设定 2020、2021、2022 三个切分点每次用切分点之前的数据训练预测之后的数据记录每个窗口的 MAE。如果三个窗口的误差基本稳定模型才有资格部署如果误差随起点年份快速变大说明模型很可能只是在“复读上一年录取率”。这个验证真的救过我一次。某次项目里随机森林在固定测试集上 R² 高达 0.94看起来完美但滚动回溯时发现模型预测的下一年录取率几乎就是上一年的复制——因为滞后录取率特征太强树模型一下就会选择“无脑复读”。这个模型在趋势平稳期还像样一旦地区政策调整、录取率掉头预测立刻失效。从那以后滚动回溯成了我所有时序预测项目的必做步骤。最后分享一个习惯每次预测项目收尾我都会把清洗后的宽表、滞后特征、每个 fold 的误差结果存成文件并在文件名里标注当时的切分时间和数据版本。录取率和经济指标每年都会更新下一次重跑脚本时新旧误差对比能直接告诉我今年的预测能力是变好了还是变差了。这个习惯不值钱但能给你省下大把重复排查时间。希望帮到你。本文还有配套的精品资源点击获取
返回列表