简介:这份资源面向具备Python基础、希望提升数据清洗与建模能力的中高级学习者与数据分析师,围绕成绩、大学录取、收入与社会经济四类真实数据集,系统演示统计描述、缺失值插补、相关性分析与一元及多元线性回归的完整流程。内容涵盖异常值处理、虚拟变量生成、数据合并与可视化等关键环节,可帮助读者独立完成课程作业或项目练习,深化对Pandas与Numpy的运用。资源包共1个docx文档,约17KB,以文字与代码说明形式呈现任务步骤与结果解释,结构紧凑便于按模块查阅。目前已有85人学习下载,适合作为数据科学实操训练与建模思路参考的配套材料。
1. 成绩、录取率和经济指标:三个数据集为什么总在同一个脚本里打架
带过几届做数据分析的人之后,我发现一个规律:凡是标题里同时出现「成绩」「录取率」「经济指标」的,最后八成会卡在同一个地方——不是模型不会调,而是三份数据的口径对不上。成绩表按学期走,录取率按年份走,经济指标按季度走,直接merge出来的结果看着有几千行,实际上有效样本可能只剩几十条。这篇笔记就围绕这个标题,把从数据清洗、特征构造、统计描述到预测建模的完整链路拆开讲一遍,重点放在「怎么让三张表能对齐」「预测时哪些参数必须调」「哪些坑我踩过」。适合已经会写 pandas 但一上真实数据就翻车的人,也适合想把统计和预测串成一条流水线的从业者。
我一般会把这类任务拆成四步:先统一时间粒度,再做缺失和异常处理,然后用统计量把数据讲清楚,最后才上预测模型。顺序反了,后面全是返工。下面按这个顺序展开,中间会给出可直接抄的代码和参数说明。
2. 把三张表对齐:时间粒度统一与合并策略
2.1 为什么不能直接 merge:粒度冲突的三种表现
成绩数据通常是「学生-学期-课程」三级,录取率是「年份-地区-批次」,经济指标是「季度-地区-指标名」。直接pd.merge会出现三种典型问题:一是行数爆炸,因为一个学生一学期有多门课,和年度录取率合并后每门课都复制一份年度值;二是时间错位,学期跨年时按年份合并会把秋季学期算到错误年份;三是地区口径不一致,有的用省份,有的用城市,合并后大量 NaN。
常见做法是先定义一个统一的时间键。我一般用「年份+季度」作为主键,学期映射到季度:春季学期归 Q1-Q2,秋季学期归 Q3-Q4。这样三张表都能落到同一个粒度上。地区键则统一到省级,城市级数据向上聚合。
import pandas as pd import numpy as np # 成绩表:学生-学期-课程 grades = pd.DataFrame({ "student_id": [1,1,2,2,3], "term": ["2022春","2022秋","2022春","2022秋","2022春"], "course": ["数学","数学","英语","英语","数学"], "score": [88, 92, 76, 81, 95] }) # 学期映射到年份和季度 term_map = { "2022春": (2022, "Q1"), "2022秋": (2022, "Q3"), } grades[["year","quarter"]] = grades["term"].apply( lambda t: pd.Series(term_map[t]) ) # 录取率表:年份-地区-批次 admission = pd.DataFrame({ "year": [2022,2022,2023,2023], "region": ["A省","B省","A省","B省"], "batch": ["本科","本科","本科","本科"], "admit_rate": [0.62, 0.55, 0.65, 0.58] }) # 经济指标表:季度-地区-指标 economy = pd.DataFrame({ "year": [2022,2022,2023,2023], "quarter": ["Q1","Q3","Q1","Q3"], "region": ["A省","A省","B省","B省"], "gdp_per_capita": [52000, 54000, 48000, 49500] })这段代码的关键在term_map:它把学期字符串转成(year, quarter)元组,后续所有合并都基于这两个字段。参数上要注意,apply返回 Series 时要用pd.Series包装,否则列名会丢。如果学期命名不规整,建议先用正则提取年份和季节,再映射。
2.2 合并顺序与聚合方式:先聚合再合并,还是先合并再聚合
很多人习惯先把成绩和录取率合并,再算平均分。这样做的问题是录取率会被重复计算。正确顺序是:先把成绩聚合到「年份-季度-地区」粒度,算出该地区该季度的平均分、及格率、样本数;再把录取率和经济指标按同样粒度合并。这样每个地区每个季度只有一行,不会出现重复计数。
# 假设成绩表已关联到地区 grades["region"] = ["A省","A省","B省","B省","A省"] # 先聚合到 年份-季度-地区 grade_agg = grades.groupby(["year","quarter","region"]).agg( avg_score=("score","mean"), pass_rate=("score", lambda s: (s>=60).mean()), student_cnt=("student_id","nunique") ).reset_index() # 再合并录取率和经济指标 merged = grade_agg.merge( admission, on=["year","region"], how="left" ).merge( economy, on=["year","quarter","region"], how="left" ) print(merged.head())聚合时pass_rate用 lambda 计算及格比例,student_cnt用nunique避免同一学生多门课被重复计数。合并用how="left"保留成绩表的所有记录,如果某地区没有录取率数据,后续要单独处理缺失,而不是直接删行。参数上,groupby的as_index=False和reset_index()效果一样,我习惯用后者,链式调用时更直观。
2.3 缺失值处理:三种填充策略的适用场景
合并后必然出现缺失。录取率缺失通常是因为该地区该年份没有招生数据,经济指标缺失可能是季度数据未发布。我一般分三种情况处理:如果缺失比例低于 5%,用同地区相邻季度均值填充;如果缺失集中在某些地区,用该地区历史中位数填充;如果缺失比例超过 30%,直接标记为「数据不足」并排除出建模样本。
# 查看缺失比例 missing_ratio = merged.isnull().mean() print(missing_ratio) # 按地区分组填充经济指标 merged["gdp_per_capita"] = merged.groupby("region")["gdp_per_capita"].transform( lambda s: s.fillna(s.median()) ) # 录取率缺失用同地区均值填充 merged["admit_rate"] = merged.groupby("region")["admit_rate"].transform( lambda s: s.fillna(s.mean()) ) # 仍然缺失的行标记并排除 merged["data_flag"] = merged[["admit_rate","gdp_per_capita"]].isnull().any(axis=1) model_data = merged[~merged["data_flag"]].copy()transform和apply的区别在这里很关键:transform返回与原表等长的序列,适合填充;apply会改变形状。填充后一定要检查data_flag的行数,如果超过总行数 20%,说明数据源本身有问题,需要回头核对采集口径,而不是硬填。
3. 统计描述与特征构造:让数据先开口说话
3.1 描述性统计的四个必看指标
在建模之前,我至少会看四个统计量:均值、中位数、标准差、偏度。成绩数据常见左偏(高分多),录取率常见右偏(低录取率地区多),经济指标通常近似正态但可能有长尾。偏度绝对值超过 1 时,考虑做对数变换或分箱。
desc = model_data[["avg_score","admit_rate","gdp_per_capita"]].describe().T desc["skew"] = model_data[["avg_score","admit_rate","gdp_per_capita"]].skew() desc["missing"] = model_data[["avg_score","admit_rate","gdp_per_capita"]].isnull().sum() print(desc)describe()默认给出 count、mean、std、min、25%、50%、75%、max。我额外加偏度和缺失数,是为了判断是否需要变换以及缺失是否集中在某列。如果admit_rate的偏度大于 1.5,我会先做 logit 变换再进模型。
3.2 构造交叉特征:录取率与经济的交互项
单纯把录取率和 GDP 放进去,模型往往学不到「经济好但录取率低」这种组合模式。我一般会构造两个交互特征:录取率除以 GDP(表示单位经济产出对应的录取机会),以及录取率与平均分的差值(表示录取难度与成绩水平的匹配度)。
# 交互特征 model_data["admit_per_gdp"] = model_data["admit_rate"] / (model_data["gdp_per_capita"] / 10000) model_data["score_admit_gap"] = model_data["avg_score"] / 100 - model_data["admit_rate"] # 分箱特征:经济水平分位数 model_data["gdp_level"] = pd.qcut( model_data["gdp_per_capita"], q=4, labels=["低","中低","中高","高"] )admit_per_gdp除以 10000 是为了让系数不至于太小,方便解读。pd.qcut按分位数分箱,避免等宽分箱在长尾数据上失效。分箱后可以进一步做 one-hot 编码,但树模型可以直接用类别编码。
3.3 时间序列特征:滞后项与滚动窗口
如果数据按季度排列,滞后项是强特征。我一般会加lag1和lag4(去年同期),以及 4 季度滚动均值。注意滞后项只能在排序后生成,且第一行必然缺失。
model_data = model_data.sort_values(["region","year","quarter"]) model_data["admit_lag1"] = model_data.groupby("region")["admit_rate"].shift(1) model_data["admit_lag4"] = model_data.groupby("region")["admit_rate"].shift(4) model_data["admit_roll4"] = model_data.groupby("region")["admit_rate"].transform( lambda s: s.rolling(4, min_periods=2).mean() )shift(1)生成上一季度值,shift(4)生成去年同期值。rolling(4, min_periods=2)表示窗口 4 但至少 2 个非空值才计算,避免早期数据全空。生成后要检查缺失比例,如果admit_lag4缺失超过 30%,说明数据跨度不足 4 个季度,需要放弃该特征。
4. 预测建模:从线性回归到梯度提升的选型与调参
4.1 基线模型:线性回归为什么仍然值得先跑
不管最后用什么模型,我都会先跑一个线性回归。原因有两个:一是系数可解释,能快速判断特征方向是否合理;二是作为基线,后续复杂模型的提升幅度才有参照。如果线性回归的 R² 已经到 0.85,上梯度提升的收益可能很小。
from sklearn.linear_model import LinearRegression from sklearn.model_selection import train_test_split from sklearn.metrics import r2_score, mean_absolute_error features = ["avg_score","gdp_per_capita","admit_per_gdp","score_admit_gap","admit_lag1"] X = model_data[features].fillna(0) y = model_data["admit_rate"] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 ) lr = LinearRegression() lr.fit(X_train, y_train) pred_lr = lr.predict(X_test) print("R2:", r2_score(y_test, pred_lr)) print("MAE:", mean_absolute_error(y_test, pred_lr)) print("系数:", dict(zip(features, lr.coef_)))fillna(0)是临时处理,正式建模前应该用上一节的填充策略。random_state=42保证可复现。系数符号很重要:如果avg_score系数为负,说明成绩越高录取率越低,这通常意味着数据口径有问题,需要回头检查。
4.2 梯度提升树的关键参数:学习率、深度、早停
梯度提升在我这类任务里通常是最终选择。关键参数有三个:learning_rate控制每棵树贡献,max_depth控制单棵树复杂度,n_estimators配合早停决定树的数量。我一般先用learning_rate=0.05、max_depth=3、n_estimators=500跑一轮,看验证集曲线。
from sklearn.ensemble import GradientBoostingRegressor from sklearn.model_selection import cross_val_score gbr = GradientBoostingRegressor( learning_rate=0.05, max_depth=3, n_estimators=500, subsample=0.8, random_state=42 ) scores = cross_val_score(gbr, X_train, y_train, cv=5, scoring="r2") print("CV R2:", scores.mean(), scores.std()) gbr.fit(X_train, y_train) pred_gbr = gbr.predict(X_test) print("Test R2:", r2_score(y_test, pred_gbr))subsample=0.8表示每棵树用 80% 样本,能降低过拟合。cross_val_score的cv=5给出 5 折交叉验证,标准差大于 0.1 说明模型不稳定,需要检查数据分布或增加样本。如果测试集 R² 比 CV 低很多,通常是过拟合,优先降max_depth或减n_estimators。
4.3 特征重要性解读:哪些特征真正在驱动预测
树模型训练后,feature_importances_给出每个特征的重要性。我一般会看前五个,如果某个交互特征重要性远高于原始特征,说明交互构造有效。但要注意,重要性高不代表因果,只代表模型依赖。
import pandas as pd importance = pd.DataFrame({ "feature": features, "importance": gbr.feature_importances_ }).sort_values("importance", ascending=False) print(importance.head())如果admit_lag1重要性最高,说明录取率有强自相关,这时候要警惕数据泄漏:如果滞后项在预测时不可得,就不能用。我一般会做两个版本,一个含滞后项用于事后分析,一个不含用于实时预测。
5. 避坑与排查:五个让我返工的血泪经验
5.1 合并后行数暴涨,但有效样本没增加
现象:merge后行数从几千变成几万,但drop_duplicates后只剩几百。原因:成绩表一个学生多门课,和年度录取率合并时每门课都复制一份年度值。解决:先聚合到目标粒度再合并,聚合时用nunique统计学生数,避免重复计数。
5.2 时间字段类型不一致导致合并为空
现象:merge后所有行都是 NaN,检查发现两边都有数据。原因:一边year是 int,一边是 string,pandas 不报错但匹配不上。解决:合并前统一astype(str)或astype(int),并在合并后检查shape[0]是否等于预期。
5.3 填充缺失后模型 R² 虚高
现象:填充后 R² 从 0.7 跳到 0.95。原因:用目标变量的均值填充了特征,造成信息泄漏。解决:填充只能用训练集的统计量,且不能使用目标变量。我一般把填充逻辑写在Pipeline里,用fit只在训练集上计算。
5.4 滞后特征在预测时不可得
现象:离线评估 R² 很高,上线后预测偏差大。原因:lag1在预测时点还没发生。解决:明确预测时点,只使用该时点之前已知的特征。如果必须用滞后项,至少滞后 2 期以上。
5.5 经济指标量纲差异导致系数不可比
现象:GDP 系数是 0.0001,成绩系数是 0.5,看起来成绩重要得多。原因:GDP 单位是元,数值几万,成绩是百分制。解决:建模前做标准化,StandardScaler后系数才可比。树模型不受量纲影响,但线性模型必须处理。
6. 进阶技巧:用分位数回归看录取率的尾部风险
普通回归预测的是均值,但录取率这种指标,尾部风险往往更值得关注。比如某地区录取率可能骤降到 0.3,均值模型会平滑掉这种极端情况。分位数回归可以给出 10%、50%、90% 分位数的预测区间,帮助判断「最差情况」和「最好情况」。
from sklearn.ensemble import GradientBoostingRegressor from sklearn.metrics import mean_pinball_loss # 分别训练三个分位数模型 quantiles = [0.1, 0.5, 0.9] models = {} for q in quantiles: gbr_q = GradientBoostingRegressor( loss="quantile", alpha=q, learning_rate=0.05, max_depth=3, n_estimators=300, random_state=42 ) gbr_q.fit(X_train, y_train) models[q] = gbr_q # 预测区间 pred_low = models[0.1].predict(X_test) pred_mid = models[0.5].predict(X_test) pred_high = models[0.9].predict(X_test) # 评估:pinball loss for q in quantiles: loss = mean_pinball_loss(y_test, models[q].predict(X_test), alpha=q) print(f"Quantile {q} pinball loss: {loss:.4f}")loss="quantile"和alpha=q是分位数回归的核心参数。mean_pinball_loss是分位数回归的标准评估指标,值越小越好。预测区间宽度(pred_high - pred_low)可以作为一个新特征,宽度大说明该样本不确定性高,适合单独标记出来人工复核。
我一般会把分位数模型的输出和均值模型对比:如果 90% 分位数预测远高于均值,说明该地区有上升潜力;如果 10% 分位数很低,说明有下行风险。这个思路在录取率预测里特别有用,因为录取率受政策影响大,尾部事件并不罕见。
最后说一个习惯:每次跑完模型,我都会把特征重要性、分位数区间、缺失比例三张表并排看一遍。如果特征重要性里出现明显不合理的特征,或者分位数区间宽度异常,先别调参,回头查数据。模型不会骗人,但数据会。希望帮到你。
本文还有配套的精品资源,点击获取